DEV: prosemirror

This commit is contained in:
Renato Atilio 2024-12-21 17:58:53 -03:00
parent 030aec0342
commit 954ad7218d
No known key found for this signature in database
GPG Key ID: CBF93DCB5CBCA1A5
59 changed files with 2031 additions and 583 deletions

View File

@ -1,6 +1,6 @@
<ComposerBody <ComposerBody
@composer={{this.composer.model}} @composer={{this.composer.model}}
@showPreview={{this.composer.showPreview}} @showPreview={{this.showPreview}}
@openIfDraft={{this.composer.openIfDraft}} @openIfDraft={{this.composer.openIfDraft}}
@typed={{this.composer.typed}} @typed={{this.composer.typed}}
@cancelled={{this.composer.cancelled}} @cancelled={{this.composer.cancelled}}

View File

@ -4,4 +4,10 @@ import { service } from "@ember/service";
export default class ComposerContainer extends Component { export default class ComposerContainer extends Component {
@service composer; @service composer;
@service site; @service site;
get showPreview() {
return (
this.composer.get("showPreview") && this.composer.get("allowPreview")
);
}
} }

View File

@ -196,6 +196,12 @@ export default class ComposerEditor extends Component {
this.appEvents.trigger(`${this.composerEventPrefix}:will-open`); this.appEvents.trigger(`${this.composerEventPrefix}:will-open`);
} }
/**
* Sets up the editor with the given text manipulation instance
*
* @param {TextManipulation} textManipulation The text manipulation instance
* @returns {(() => void)} destructor function
*/
@bind @bind
setupEditor(textManipulation) { setupEditor(textManipulation) {
this.textManipulation = textManipulation; this.textManipulation = textManipulation;
@ -208,13 +214,14 @@ export default class ComposerEditor extends Component {
this._throttledSyncEditorAndPreviewScroll this._throttledSyncEditorAndPreviewScroll
); );
if (!this.site.mobileView) {
this.composer.set("showPreview", this.textManipulation.allowPreview);
}
this.composer.set("allowPreview", this.textManipulation.allowPreview); this.composer.set("allowPreview", this.textManipulation.allowPreview);
// Focus on the body unless we have a title if (
if (!this.get("composer.model.canEditTitle")) { // Focus on the body unless we have a title
!this.get("composer.model.canEditTitle") ||
// Or focus is in the body (e.g. when the editor is destroyed)
document.activeElement.tagName === "BODY"
) {
this.textManipulation.putCursorAtEnd(); this.textManipulation.putCursorAtEnd();
} }

View File

@ -110,7 +110,7 @@ export default class TextareaEditor extends Component {
@input={{@change}} @input={{@change}}
@focusIn={{@focusIn}} @focusIn={{@focusIn}}
@focusOut={{@focusOut}} @focusOut={{@focusOut}}
class="d-editor-input" class={{@class}}
@id={{@id}} @id={{@id}}
{{this.registerTextarea}} {{this.registerTextarea}}
/> />

View File

@ -1,39 +1,49 @@
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import Component from "@glimmer/component";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon"; import icon from "discourse-common/helpers/d-icon";
const ComposerToggleSwitch = <template> export default class ComposerToggleSwitch extends Component {
{{! template-lint-disable no-redundant-role }} @action
<button mouseDown(event) {
class="{{concatClass if (this.args.preventFocus) {
'composer-toggle-switch' event.preventDefault();
(if @state '--rte' '--markdown') }
}}" }
type="button"
role="switch"
aria-pressed={{if @state "true" "false"}}
...attributes
>
{{! template-lint-enable no-redundant-role }}
<span class="composer-toggle-switch__slider" focusable="false"> <template>
<span {{! template-lint-disable no-redundant-role }}
class={{concatClass <button
"composer-toggle-switch__left-icon" class="{{concatClass
(unless @state "--active") 'composer-toggle-switch'
}} (if @state '--rte' '--markdown')
aria-hidden="true" }}"
focusable="false" type="button"
>{{icon "fab-markdown"}}</span> role="switch"
<span aria-pressed={{if @state "true" "false"}}
class={{concatClass {{on "mousedown" this.mouseDown}}
"composer-toggle-switch__right-icon" {{on "mouseup" this.mouseDown}}
(if @state "--active") ...attributes
}} >
aria-hidden="true" {{! template-lint-enable no-redundant-role }}
focusable="false"
>{{icon "a"}}</span>
</span>
</button>
</template>;
export default ComposerToggleSwitch; <span class="composer-toggle-switch__slider">
<span
class={{concatClass
"composer-toggle-switch__left-icon"
(unless @state "--active")
}}
aria-hidden="true"
>{{icon "fab-markdown"}}</span>
<span
class={{concatClass
"composer-toggle-switch__right-icon"
(if @state "--active")
}}
aria-hidden="true"
>{{icon "a"}}</span>
</span>
</button>
</template>
}

View File

@ -10,6 +10,7 @@
<div class="d-editor-button-bar" role="toolbar"> <div class="d-editor-button-bar" role="toolbar">
{{#if this.siteSettings.experimental_rich_editor}} {{#if this.siteSettings.experimental_rich_editor}}
<Composer::ToggleSwitch <Composer::ToggleSwitch
@preventFocus={{true}}
@state={{this.isRichEditorEnabled}} @state={{this.isRichEditorEnabled}}
{{on "click" this.toggleRichEditor}} {{on "click" this.toggleRichEditor}}
/> />
@ -47,6 +48,7 @@
<ConditionalLoadingSpinner @condition={{this.loading}} /> <ConditionalLoadingSpinner @condition={{this.loading}} />
<this.editorComponent <this.editorComponent
@class="d-editor-input"
@onSetup={{this.setupEditor}} @onSetup={{this.setupEditor}}
@markdownOptions={{this.markdownOptions}} @markdownOptions={{this.markdownOptions}}
@keymap={{this.keymap}} @keymap={{this.keymap}}

View File

@ -22,6 +22,7 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts"; import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
import { linkSeenMentions } from "discourse/lib/link-mentions"; import { linkSeenMentions } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes"; import { loadOneboxes } from "discourse/lib/load-oneboxes";
import loadRichEditor from "discourse/lib/load-rich-editor";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import userSearch from "discourse/lib/user-search"; import userSearch from "discourse/lib/user-search";
import { import {
@ -61,6 +62,7 @@ export default class DEditor extends Component {
@service menu; @service menu;
@tracked editorComponent; @tracked editorComponent;
/** @type {TextManipulation} */
@tracked textManipulation; @tracked textManipulation;
ready = false; ready = false;
@ -84,7 +86,7 @@ export default class DEditor extends Component {
this.siteSettings.experimental_rich_editor && this.siteSettings.experimental_rich_editor &&
this.keyValueStore.get("d-editor-prefers-rich-editor") === "true" this.keyValueStore.get("d-editor-prefers-rich-editor") === "true"
) { ) {
this.editorComponent = await this.loadProsemirrorEditor(); this.editorComponent = await loadRichEditor();
} else { } else {
this.editorComponent = TextareaEditor; this.editorComponent = TextareaEditor;
} }
@ -635,6 +637,12 @@ export default class DEditor extends Component {
this.set("isEditorFocused", false); this.set("isEditorFocused", false);
} }
/**
* Sets up the editor with the given text manipulation instance
*
* @param {TextManipulation} textManipulation The text manipulation instance
* @returns {(() => void)} destructor function
*/
@action @action
setupEditor(textManipulation) { setupEditor(textManipulation) {
this.textManipulation = textManipulation; this.textManipulation = textManipulation;
@ -666,8 +674,7 @@ export default class DEditor extends Component {
async toggleRichEditor() { async toggleRichEditor() {
this.editorComponent = this.isRichEditorEnabled this.editorComponent = this.isRichEditorEnabled
? TextareaEditor ? TextareaEditor
: await this.loadProsemirrorEditor(); : await loadRichEditor();
scheduleOnce("afterRender", this, this.focus);
this.keyValueStore.set({ this.keyValueStore.set({
key: "d-editor-prefers-rich-editor", key: "d-editor-prefers-rich-editor",
@ -675,13 +682,6 @@ export default class DEditor extends Component {
}); });
} }
async loadProsemirrorEditor() {
this.prosemirrorEditorClass ??= (
await import("discourse/static/prosemirror/components/prosemirror-editor")
).default;
return this.prosemirrorEditorClass;
}
focus() { focus() {
this.textManipulation.focus(); this.textManipulation.focus();
} }
@ -693,7 +693,7 @@ export default class DEditor extends Component {
} }
get isRichEditorEnabled() { get isRichEditorEnabled() {
return this.editorComponent === this.prosemirrorEditorClass; return this.editorComponent !== TextareaEditor;
} }
setupEvents() { setupEvents() {

View File

@ -114,6 +114,7 @@ export default function (options) {
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea; const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
let inputSelectedItems = []; let inputSelectedItems = [];
/** @type {AutocompleteHandler} */
options.textHandler ??= new TextareaAutocompleteHandler(me[0]); options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
function handlePaste() { function handlePaste() {
@ -249,14 +250,14 @@ export default function (options) {
options.textHandler.getCaretPosition(); options.textHandler.getCaretPosition();
} }
options.textHandler.replaceTerm({ options.textHandler.replaceTerm(
start: completeStart, completeStart,
end: completeEnd, completeEnd,
term: (options.preserveKey ? options.key || "" : "") + term, (options.preserveKey ? options.key || "" : "") + term
}); );
if (options && options.afterComplete) { if (options && options.afterComplete) {
options.afterComplete(options.textHandler.value, event); options.afterComplete(options.textHandler.getValue(), event);
} }
} }
} }
@ -481,7 +482,9 @@ export default function (options) {
if ( if (
(term.length !== 0 && term.trim().length === 0) || (term.length !== 0 && term.trim().length === 0) ||
// close unless the caret is at the end of a word, like #line|<- // close unless the caret is at the end of a word, like #line|<-
options.textHandler.value[options.textHandler.getCaretPosition()]?.trim() options.textHandler
.getValue()
[options.textHandler.getCaretPosition()]?.trim()
) { ) {
closeAutocomplete(); closeAutocomplete();
return null; return null;
@ -549,11 +552,11 @@ export default function (options) {
} }
let cp = options.textHandler.getCaretPosition(); let cp = options.textHandler.getCaretPosition();
const key = options.textHandler.value[cp - 1]; const key = options.textHandler.getValue()[cp - 1];
if (options.key) { if (options.key) {
if (options.onKeyUp && key !== options.key) { if (options.onKeyUp && key !== options.key) {
let match = options.onKeyUp(options.textHandler.value, cp); let match = options.onKeyUp(options.textHandler.getValue(), cp);
if (match) { if (match) {
completeStart = cp - match[0].length; completeStart = cp - match[0].length;
@ -565,7 +568,7 @@ export default function (options) {
if (completeStart === null && cp > 0) { if (completeStart === null && cp > 0) {
if (key === options.key) { if (key === options.key) {
let prevChar = options.textHandler.value.charAt(cp - 2); let prevChar = options.textHandler.getValue().charAt(cp - 2);
if ( if (
(await checkTriggerRule()) && (await checkTriggerRule()) &&
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar)) (!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar))
@ -575,10 +578,9 @@ export default function (options) {
} }
} }
} else if (completeStart !== null) { } else if (completeStart !== null) {
let term = options.textHandler.value.substring( let term = options.textHandler
completeStart + (options.key ? 1 : 0), .getValue()
cp .substring(completeStart + (options.key ? 1 : 0), cp);
);
updateAutoComplete(dataSource(term, options)); updateAutoComplete(dataSource(term, options));
} }
} }
@ -601,12 +603,12 @@ export default function (options) {
while (prevIsGood && caretPos >= 0) { while (prevIsGood && caretPos >= 0) {
caretPos -= 1; caretPos -= 1;
prev = options.textHandler.value[caretPos]; prev = options.textHandler.getValue()[caretPos];
stopFound = prev === options.key; stopFound = prev === options.key;
if (stopFound) { if (stopFound) {
prev = options.textHandler.value[caretPos - 1]; prev = options.textHandler.getValue()[caretPos - 1];
const shouldTrigger = await checkTriggerRule({ backSpace }); const shouldTrigger = await checkTriggerRule({ backSpace });
if ( if (
@ -614,10 +616,9 @@ export default function (options) {
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev)) (prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
) { ) {
start = caretPos; start = caretPos;
term = options.textHandler.value.substring( term = options.textHandler
caretPos + 1, .getValue()
initialCaretPos .substring(caretPos + 1, initialCaretPos);
);
end = caretPos + term.length; end = caretPos + term.length;
break; break;
} }
@ -648,7 +649,7 @@ export default function (options) {
inputSelectedItems.push(""); inputSelectedItems.push("");
} }
const value = options.textHandler.value; const value = options.textHandler.getValue();
if (typeof inputSelectedItems[0] === "string" && value.length > 0) { if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
inputSelectedItems.pop(); inputSelectedItems.pop();
inputSelectedItems.push(value); inputSelectedItems.push(value);
@ -694,7 +695,7 @@ export default function (options) {
// allow people to right arrow out of completion // allow people to right arrow out of completion
if ( if (
e.which === keys.rightArrow && e.which === keys.rightArrow &&
options.textHandler.value[cp] === " " options.textHandler.getValue()[cp] === " "
) { ) {
closeAutocomplete(); closeAutocomplete();
return true; return true;
@ -770,10 +771,9 @@ export default function (options) {
return true; return true;
} }
term = options.textHandler.value.substring( term = options.textHandler
completeStart + (options.key ? 1 : 0), .getValue()
cp .substring(completeStart + (options.key ? 1 : 0), cp);
);
if (completeStart === cp && term === options.key) { if (completeStart === cp && term === options.key) {
closeAutocomplete(); closeAutocomplete();

View File

@ -7,32 +7,59 @@ const CUSTOM_NODE_VIEW = {};
const CUSTOM_INPUT_RULES = []; const CUSTOM_INPUT_RULES = [];
const CUSTOM_PLUGINS = []; const CUSTOM_PLUGINS = [];
const MULTIPLE_ALLOWED = { span: true, wrap_bbcode: false, bbcode: true }; /**
* Node names to be processed allowing multiple occurrences, with its respective `noCloseToken` boolean definition
* @type {Record<string, boolean>}
*/
const MULTIPLE_ALLOWED = { span: false, wrap_bbcode: true, bbcode: false };
/** @typedef {import('prosemirror-state').PluginSpec} PluginSpec */
/** @typedef {((params: PluginParams) => PluginSpec)} RichPluginFn */
/** @typedef {PluginSpec | RichPluginFn} RichPlugin */
/**
* @typedef InputRuleObject
* @property {RegExp} match
* @property {string | ((state: import('prosemirror-state').EditorState, match: RegExpMatchArray, start: number, end: number) => import('prosemirror-state').Transaction | null)} handler
* @property {{ undoable?: boolean, inCode?: boolean | "only" }} [options]
*/
/**
* @typedef InputRuleParams
* @property {import('prosemirror-model').Schema} schema
* @property {Function} markInputRule
*/
/** @typedef {((params: InputRuleParams) => InputRuleObject) | InputRuleObject} RichInputRule */
/** @typedef {import("markdown-it").Token} MarkdownItToken */
/** @typedef {(state: unknown, token: MarkdownItToken, tokenStream: MarkdownItToken[], index: number) => boolean | void} ParseFunction */
/** @typedef {import("prosemirror-markdown").ParseSpec | ParseFunction} RichParseSpec */
/**
* @typedef {(state: import("prosemirror-markdown").MarkdownSerializerState, node: import("prosemirror-model").Node, parent: import("prosemirror-model").Node, index: number) => void} SerializeNodeFn
*/
/** /**
* @typedef {import('prosemirror-state').PluginSpec} PluginSpec
* @typedef {((pluginClass: typeof import('prosemirror-state').Plugin) => PluginSpec)} RichPluginFn
* @typedef {PluginSpec | RichPluginFn} RichPlugin
*
* @typedef {Object} RichEditorExtension * @typedef {Object} RichEditorExtension
* @property {Object<string, import('prosemirror-model').NodeSpec>} [nodeSpec] * @property {Record<string, import('prosemirror-model').NodeSpec>} [nodeSpec]
* Map containing Prosemirror node spec definitions, each key being the node name * Map containing Prosemirror node spec definitions, each key being the node name
* See https://prosemirror.net/docs/ref/#model.NodeSpec * See https://prosemirror.net/docs/ref/#model.NodeSpec
* @property {Object<string, import('prosemirror-model').MarkSpec>} [markSpec] * @property {Record<string, import('prosemirror-model').MarkSpec>} [markSpec]
* Map containing Prosemirror mark spec definitions, each key being the mark name * Map containing Prosemirror mark spec definitions, each key being the mark name
* See https://prosemirror.net/docs/ref/#model.MarkSpec * See https://prosemirror.net/docs/ref/#model.MarkSpec
* @property {Array<typeof import("prosemirror-inputrules").InputRule>} [inputRules] * @property {RichInputRule | Array<RichInputRule>} [inputRules]
* Prosemirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule * Prosemirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule
* can be a function returning an array or an array of input rules * can be a function returning an array or an array of input rules
* @property {Object<string, import('prosemirror-markdown').NodeSerializerSpec>} [serializeNode] * @property {Record<string, SerializeNodeFn>} [serializeNode]
* Node serialization definition * Node serialization definition
* @property {Object<string, import('prosemirror-markdown').MarkSerializerSpec>} [serializeMark] * @property {Record<string, import('prosemirror-markdown').MarkSerializerSpec>} [serializeMark]
* Mark serialization definition * Mark serialization definition
* @property {Object<string, import('prosemirror-markdown').ParseSpec>} [parse] * @property {Record<string, RichParseSpec>} [parse]
* Markdown-it token parse definition * Markdown-it token parse definition
* @property {Array<RichPlugin>} [plugins] * @property {RichPlugin | Array<RichPlugin>} [plugins]
* ProseMirror plugins * ProseMirror plugins
* @property {Object<string, import('prosemirror-view').NodeView>} [nodeViews] * @property {Record<string, import('prosemirror-view').NodeViewConstructor>} [nodeViews]
*/ */
/** /**
@ -124,37 +151,8 @@ export function getPlugins() {
return CUSTOM_PLUGINS; return CUSTOM_PLUGINS;
} }
function generateMultipleParser(tokenName, list, isOpenClose) { function generateMultipleParser(tokenName, list, noCloseToken) {
if (isOpenClose) { if (noCloseToken) {
return {
[`${tokenName}_open`](state, token, tokens, i) {
if (!list) {
return;
}
for (let parser of list) {
if (parser(state, token, tokens, i)) {
return;
}
}
// No support for nested missing definitions
state[`skip${tokenName}Close`] ??= [];
},
[`${tokenName}_close`](state) {
if (!list) {
return;
}
if (state[`skip${tokenName}Close`]) {
state[`skip${tokenName}Close`] = false;
return;
}
state.closeNode();
},
};
} else {
return { return {
[tokenName](state, token, tokens, i) { [tokenName](state, token, tokens, i) {
if (!list) { if (!list) {
@ -162,10 +160,48 @@ function generateMultipleParser(tokenName, list, isOpenClose) {
} }
for (let parser of list) { for (let parser of list) {
// Stop once a parse function returns true
if (parser(state, token, tokens, i)) { if (parser(state, token, tokens, i)) {
return; return;
} }
} }
throw new Error(
`No parser to process ${tokenName} token. Tag: ${
token.tag
}, attrs: ${JSON.stringify(token.attrs)}`
);
},
};
} else {
return {
[`${tokenName}_open`](state, token, tokens, i) {
if (!list) {
return;
}
state[`skip${tokenName}CloseStack`] ??= [];
let handled = false;
for (let parser of list) {
if (parser(state, token, tokens, i)) {
handled = true;
break;
}
}
state[`skip${tokenName}CloseStack`].push(!handled);
},
[`${tokenName}_close`](state) {
if (!list || !state[`skip${tokenName}CloseStack`]) {
return;
}
const skipCurrentLevel = state[`skip${tokenName}CloseStack`].pop();
if (skipCurrentLevel) {
return;
}
state.closeNode();
}, },
}; };
} }
@ -182,11 +218,11 @@ function addParser(token, parse) {
export function getParsers() { export function getParsers() {
const parsers = { ...CUSTOM_PARSER }; const parsers = { ...CUSTOM_PARSER };
for (let [token, isOpenClose] of Object.entries(MULTIPLE_ALLOWED)) { for (const [tokenName, noCloseToken] of Object.entries(MULTIPLE_ALLOWED)) {
delete parsers[token]; delete parsers[tokenName];
Object.assign( Object.assign(
parsers, parsers,
generateMultipleParser(token, CUSTOM_PARSER[token], isOpenClose) generateMultipleParser(tokenName, CUSTOM_PARSER[tokenName], noCloseToken)
); );
} }

View File

@ -0,0 +1,298 @@
/**
* Interface for text manipulation with an underlying editor implementation.
*
* @interface TextManipulation
*/
/**
* Whether the editor allows a preview being shown
* @name TextManipulation#allowPreview
* @type {boolean}
* @readonly
*/
/**
* Focuses the editor
*
* @method
* @name TextManipulation#focus
* @returns {void}
*/
/**
* Blurs and focuses the editor
*
* @method
* @name TextManipulation#blurAndFocus
* @returns {void}
*/
/**
* Indents/un-indents the current selection
*
* @method
* @name TextManipulation#indentSelection
* @param {string} direction The direction to indent in. Either "right" or "left"
* @returns {void}
*/
/**
* Configures an Autocomplete for the editor
*
* @method
* @name TextManipulation#autocomplete
* @param {unknown} options The options for the jQuery autocomplete
* @returns {void}
*/
/**
* Checks if the current selection is in a code block
*
* @method
* @name TextManipulation#inCodeBlock
* @returns {Promise<boolean>}
*/
/**
* Gets the current selection
*
* @method
* @name TextManipulation#getSelected
* @param {unknown} trimLeading
* @returns {unknown}
*/
/**
* Selects the text from the given range
*
* @method
* @name TextManipulation#selectText
* @param {number} from
* @param {number} to
* @param {unknown} [options]
* @returns {void}
*/
/**
* Applies the given head/tail to the selected text
*
* @method
* @name TextManipulation#applySurround
* @param {string} selected The selected text
* @param {string} head The text to be inserted before the selection
* @param {string} tail The text to be inserted after the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
*/
/**
* Applies the list format to the selected text
*
* @method
* @name TextManipulation#applyList
* @param {string} selected The selected text
* @param {string} head The text to be inserted before the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
*/
/**
* Formats the current selection as code
*
* @method
* @name TextManipulation#formatCode
* @returns {void}
*/
/**
* Adds text
*
* @method
* @name TextManipulation#addText
* @param {string} selected The selected text
* @param {string} text The text to be inserted
*/
/**
* Toggles the text (LTR/RTL) direction
*
* @method
* @name TextManipulation#toggleDirection
* @returns {void}
*/
/**
* Replaces text
*
* @method
* @name TextManipulation#replaceText
* @param {string} oldValue The old value
* @param {string} newValue The new value
* @param {unknown} [opts]
* @returns {void}
*/
/**
* Handles the paste event
*
* @method
* @name TextManipulation#paste
* @param {ClipboardEvent} event The paste event
* @returns {void}
*/
/**
* Inserts the block
*
* @method
* @name TextManipulation#insertBlock
* @param {string} block The block to be inserted
* @returns {void}
*/
/**
* Inserts text
*
* @method
* @name TextManipulation#insertText
* @param {string} text The text to be inserted
* @returns {void}
*/
/**
* Applies the head/tail to the selected text
*
* @method
* @name TextManipulation#applySurroundSelection
* @param {string} head The text to be inserted before the selection
* @param {string} tail The text to be inserted after the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
* @returns {void}
*/
/**
* Puts cursor at the end of the editor
*
* @method
* @name TextManipulation#putCursorAtEnd
* @returns {void}
*/
/**
* The placeholder handler instance
*
* @name TextManipulation#placeholder
* @type {PlaceholderHandler}
* @readonly
*/
/**
* Interface for handling placeholders on upload events
*
* @interface PlaceholderHandler
*/
/**
* Inserts a file
*
* @method
* @name PlaceholderHandler#insert
* @param {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile} file The uploaded file
* @returns {void}
*/
/**
* Success event for file upload
*
* @method
* @name PlaceholderHandler#success
* @param {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile} file The uploaded file
* @param {string} markdown The markdown for the uploaded file
* @returns {void}
*/
/**
* Cancels all uploads
*
* @method
* @name PlaceholderHandler#cancelAll
* @returns {void}
*/
/**
* Cancels one uploaded file
*
* @method
* @name PlaceholderHandler#cancel
* @param {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile} file The uploaded file
* @returns {void}
*/
/**
* Progress event
*
* @method
* @name PlaceholderHandler#progress
* @param {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile} file The uploaded file
* @returns {void}
*/
/**
* Progress complete event
*
* @method
* @name PlaceholderHandler#progressComplete
* @param {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile} file The uploaded file
* @returns {void}
*/
/**
* Interface for the Autocomplete handler
*
* @interface AutocompleteHandler
*/
/**
* Replaces the range with the given text
*
* @method
* @name AutocompleteHandler#replaceTerm
* @param {number} start The start of the range
* @param {number} end The end of the range
* @param {string} text The text to be inserted
* @returns {void}
*/
/**
* Gets the caret position
*
* @method
* @name AutocompleteHandler#getCaretPosition
* @returns {number}
*/
/**
* Checks if the current selection is in a code block
*
* @method
* @name AutocompleteHandler#inCodeBlock
* @returns {Promise<boolean>}
*/
/**
* Gets the caret coordinates
*
* @method
* @name AutocompleteHandler#getCaretCoords
* @param {number} caretPositon The caret position to get the coords for
* @returns {{ top: number, left: number }}
*/
/**
* Gets the current value for the autocomplete
*
* @method
* @name AutocompleteHandler#getValue
* @returns {string}
*/

View File

@ -58,7 +58,7 @@ export default async function highlightSyntax(elem, siteSettings, session) {
}); });
} }
async function ensureHighlightJs(langFile) { export async function ensureHighlightJs(langFile) {
try { try {
if (!hljsLoadPromise) { if (!hljsLoadPromise) {
hljsLoadPromise = loadHighlightJs(langFile); hljsLoadPromise = loadHighlightJs(langFile);

View File

@ -0,0 +1,5 @@
export default async function loadRichEditor() {
return (
await import("discourse/static/prosemirror/components/prosemirror-editor")
).default;
}

View File

@ -43,12 +43,15 @@ function getHead(head, prev) {
} }
} }
/** @implements {TextManipulation} */
export default class TextareaTextManipulation { export default class TextareaTextManipulation {
@service appEvents; @service appEvents;
@service siteSettings; @service siteSettings;
@service capabilities; @service capabilities;
@service currentUser; @service currentUser;
allowPreview = true;
eventPrefix; eventPrefix;
textarea; textarea;
$textarea; $textarea;
@ -76,10 +79,6 @@ export default class TextareaTextManipulation {
return this.textarea.value; return this.textarea.value;
} }
get allowPreview() {
return true;
}
// ensures textarea scroll position is correct // ensures textarea scroll position is correct
blurAndFocus() { blurAndFocus() {
this.textarea?.blur(); this.textarea?.blur();
@ -831,7 +830,7 @@ export default class TextareaTextManipulation {
} }
autocomplete(options) { autocomplete(options) {
return this.$textarea.autocomplete( this.$textarea.autocomplete(
options instanceof Object options instanceof Object
? { textHandler: this.autocompleteHandler, ...options } ? { textHandler: this.autocompleteHandler, ...options }
: options : options
@ -849,6 +848,7 @@ function insertAtTextarea(textarea, start, end, text) {
} }
} }
/** @implements {AutocompleteHandler} */
export class TextareaAutocompleteHandler { export class TextareaAutocompleteHandler {
textarea; textarea;
$textarea; $textarea;
@ -858,12 +858,13 @@ export class TextareaAutocompleteHandler {
this.$textarea = $(textarea); this.$textarea = $(textarea);
} }
get value() { getValue() {
return this.textarea.value; return this.textarea.value;
} }
replaceTerm({ start, end, term }) { replaceTerm(start, end, term) {
const space = this.value.substring(end + 1, end + 2) === " " ? "" : " "; const space =
this.getValue().substring(end + 1, end + 2) === " " ? "" : " ";
insertAtTextarea(this.textarea, start, end + 1, term + space); insertAtTextarea(this.textarea, start, end + 1, term + space);
setCaretPosition(this.textarea, start + 1 + term.trim().length); setCaretPosition(this.textarea, start + 1 + term.trim().length);
} }
@ -884,9 +885,11 @@ export class TextareaAutocompleteHandler {
} }
} }
/** @implements {PlaceholderHandler} */
class TextareaPlaceholderHandler { class TextareaPlaceholderHandler {
@service composer; @service composer;
/** @type {TextareaTextManipulation} */
textManipulation; textManipulation;
#placeholders = {}; #placeholders = {};

View File

@ -52,6 +52,7 @@ export default class UppyComposerUpload {
uploadPreProcessors; uploadPreProcessors;
uploadHandlers; uploadHandlers;
/** @type {TextManipulation} */
textManipulation; textManipulation;
#inProgressUploads = []; #inProgressUploads = [];

View File

@ -4,7 +4,7 @@ import loadPluginFeatures from "./features";
import MentionsParser from "./mentions-parser"; import MentionsParser from "./mentions-parser";
import buildOptions from "./options"; import buildOptions from "./options";
function buildEngine(options) { export function buildEngine(options) {
return DiscourseMarkdownIt.withCustomFeatures( return DiscourseMarkdownIt.withCustomFeatures(
loadPluginFeatures() loadPluginFeatures()
).withOptions(buildOptions(options)); ).withOptions(buildOptions(options));

View File

@ -1,9 +1,8 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import { getOwner } from "@ember/owner"; import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service"; import { service } from "@ember/service";
import "../extensions"; import "../extensions";
@ -11,14 +10,16 @@ import {
getNodeViews, getNodeViews,
getPlugins, getPlugins,
} from "discourse/lib/composer/rich-editor-extensions"; } from "discourse/lib/composer/rich-editor-extensions";
import * as utils from "../lib/plugin-utils";
import * as ProsemirrorModel from "prosemirror-model"; import * as ProsemirrorModel from "prosemirror-model";
import * as ProsemirrorView from "prosemirror-view"; import * as ProsemirrorView from "prosemirror-view";
import { createHighlight } from "../plugins/code-highlight"; import * as ProsemirrorState from "prosemirror-state";
import * as ProsemirrorHistory from "prosemirror-history";
import * as ProsemirrorTransform from "prosemirror-transform";
import { baseKeymap } from "prosemirror-commands"; import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor"; import { dropCursor } from "prosemirror-dropcursor";
import { history } from "prosemirror-history"; import { history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap"; import { keymap } from "prosemirror-keymap";
import * as ProsemirrorState from "prosemirror-state";
import { EditorState, Plugin } from "prosemirror-state"; import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view"; import { EditorView } from "prosemirror-view";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
@ -29,23 +30,82 @@ import { convertToMarkdown } from "../lib/serializer";
import { buildInputRules } from "../plugins/inputrules"; import { buildInputRules } from "../plugins/inputrules";
import { buildKeymap } from "../plugins/keymap"; import { buildKeymap } from "../plugins/keymap";
import placeholder from "../plugins/placeholder"; import placeholder from "../plugins/placeholder";
import { gapCursor } from "prosemirror-gapcursor";
/**
* @typedef PluginContext
* @property {string} placeholder
* @property {number} topicId
* @property {number} categoryId
* @property {import("discourse/models/session").default} session
*/
/**
* @typedef PluginParams
* @property {typeof import("../lib/plugin-utils")} utils
* @property {typeof import('prosemirror-model')} pmModel
* @property {typeof import('prosemirror-view')} pmView
* @property {typeof import('prosemirror-state')} pmState
* @property {typeof import('prosemirror-history')} pmHistory
* @property {typeof import('prosemirror-transform')} pmTransform
* @property {() => PluginContext} getContext
*/
/**
* @typedef ProsemirrorEditorArgs
* @property {string} [value] The markdown content to be rendered in the editor
* @property {string} [placeholder] The placeholder text to be displayed when the editor is empty
* @property {boolean} [disabled] Whether the editor should be disabled
* @property {Record<string, () => void>} [keymap] A mapping of keybindings to commands
* @property {[import('prosemirror-state').Plugin]} [plugins] A list of plugins to be used in the editor (it will override any plugins from extensions)
* @property {Record<string, import('prosemirror-view').NodeViewConstructor>} [nodeViews] A mapping of node names to node view components (it will override any node views from extensions)
* @property {import('prosemirror-state').Schema} [schema] The schema to be used in the editor (it will override the default schema)
* @property {(value: string) => void} [change] A callback called when the editor content changes
* @property {() => void} [focusIn] A callback called when the editor gains focus
* @property {() => void} [focusOut] A callback called when the editor loses focus
* @property {(textManipulation: TextManipulation) => void} [onSetup] A callback called when the editor is set up
* @property {number} [topicId] The ID of the topic being edited, if any
* @property {number} [categoryId] The ID of the category of the topic being edited, if any
* @property {string} [class] The class to be added to the ProseMirror contentEditable editor
*/
/**
* @typedef ProsemirrorEditorSignature
* @property {ProsemirrorEditorArgs} Args
*/
/**
* @extends Component<ProsemirrorEditorSignature>
*/
export default class ProsemirrorEditor extends Component { export default class ProsemirrorEditor extends Component {
@service appEvents; @service session;
@service menu; @service dialog;
@service siteSettings;
@tracked rootElement; schema = this.args.schema ?? createSchema();
editorContainerId = guidFor(this);
schema = createSchema();
view; view;
state;
plugins = this.args.plugins; plugins = this.args.plugins;
@action #lastSerialized;
async setup() {
this.rootElement = document.getElementById(this.editorContainerId);
const keymapFromArgs = Object.entries(this.args.keymap).reduce( get pluginParams() {
return {
utils,
pmState: ProsemirrorState,
pmModel: ProsemirrorModel,
pmView: ProsemirrorView,
pmHistory: ProsemirrorHistory,
pmTransform: ProsemirrorTransform,
getContext: () => ({
placeholder: this.args.placeholder,
topicId: this.args.topicId,
categoryId: this.args.categoryId,
session: this.session,
}),
};
}
get keymapFromArgs() {
return Object.entries(this.args.keymap ?? {}).reduce(
(acc, [key, value]) => { (acc, [key, value]) => {
// original keymap uses itsatrap format // original keymap uses itsatrap format
acc[key.replaceAll("+", "-")] = value; acc[key.replaceAll("+", "-")] = value;
@ -53,45 +113,45 @@ export default class ProsemirrorEditor extends Component {
}, },
{} {}
); );
}
@action
setup(container) {
const params = this.pluginParams;
const pluginList = getPlugins()
.flatMap((plugin) => this.processPlugin(plugin, params))
// filter async plugins from initial load
.filter(Boolean);
this.plugins ??= [ this.plugins ??= [
buildInputRules(this.schema), buildInputRules(this.schema),
keymap(buildKeymap(this.schema, keymapFromArgs)), keymap(buildKeymap(this.schema, this.keymapFromArgs)),
keymap(baseKeymap), keymap(baseKeymap),
dropCursor({ color: "var(--primary)" }), dropCursor({ color: "var(--primary)" }),
gapCursor(),
history(), history(),
placeholder(this.args.placeholder), placeholder(),
createHighlight(), ...pluginList,
...getPlugins().map((plugin) =>
typeof plugin === "function"
? plugin({
...ProsemirrorState,
...ProsemirrorModel,
...ProsemirrorView,
})
: new Plugin(plugin)
),
]; ];
this.state = EditorState.create({ const state = EditorState.create({
schema: this.schema, schema: this.schema,
plugins: this.plugins, plugins: this.plugins,
}); });
this.view = new EditorView(this.rootElement, { this.view = new EditorView(container, {
discourse: { getContext: params.getContext,
topicId: this.args.topicId,
categoryId: this.args.categoryId,
},
nodeViews: this.args.nodeViews ?? getNodeViews(), nodeViews: this.args.nodeViews ?? getNodeViews(),
state: this.state, state,
attributes: { class: "d-editor-input d-editor__editable" }, attributes: { class: this.args.class },
editable: () => this.args.disabled !== true,
dispatchTransaction: (tr) => { dispatchTransaction: (tr) => {
this.view.updateState(this.view.state.apply(tr)); this.view.updateState(this.view.state.apply(tr));
if (tr.docChanged && tr.getMeta("addToHistory") !== false) { if (tr.docChanged && tr.getMeta("addToHistory") !== false) {
// TODO(renato): avoid calling this on every change // TODO(renato): avoid calling this on every change
const value = convertToMarkdown(this.view.state.doc); const value = convertToMarkdown(this.view.state.doc);
this.#lastSerialized = value;
this.args.change?.({ target: { value } }); this.args.change?.({ target: { value } });
} }
}, },
@ -100,7 +160,10 @@ export default class ProsemirrorEditor extends Component {
this.args.focusIn?.(); this.args.focusIn?.();
return false; return false;
}, },
blur: () => { blur: (view, event) => {
if (!event.relatedTarget) {
return false;
}
this.args.focusOut?.(); this.args.focusOut?.();
return false; return false;
}, },
@ -114,38 +177,93 @@ export default class ProsemirrorEditor extends Component {
}); });
this.textManipulation = new TextManipulation(getOwner(this), { this.textManipulation = new TextManipulation(getOwner(this), {
markdownOptions: this.args.markdownOptions,
schema: this.schema, schema: this.schema,
view: this.view, view: this.view,
}); });
this.destructor = this.args.onSetup(this.textManipulation); this.destructor = this.args.onSetup?.(this.textManipulation);
this.convertFromValue(); this.convertFromValue();
} }
@bind @bind
convertFromValue() { convertFromValue() {
const doc = convertFromMarkdown(this.schema, this.args.value); // Ignore the markdown we just serialized
if (this.args.value === this.#lastSerialized) {
return;
}
// console.log("Resulting doc:", doc); let doc;
try {
doc = convertFromMarkdown(this.schema, this.args.value);
} catch (e) {
console.error(e);
this.dialog.alert(e.message);
return;
}
const tr = this.state.tr const tr = this.view.state.tr;
.replaceWith(0, this.state.doc.content.size, doc.content) tr.replaceWith(0, this.view.state.doc.content.size, doc.content).setMeta(
.setMeta("addToHistory", false); "addToHistory",
false
);
this.view.updateState(this.view.state.apply(tr)); this.view.updateState(this.view.state.apply(tr));
} }
@action @action
teardown() { teardown() {
this.destructor?.(); this.destructor?.();
this.view.destroy();
}
@action
updateContext(element, [key, value]) {
this.view.dispatch(
this.view.state.tr
.setMeta("addToHistory", false)
.setMeta("discourseContextChanged", { key, value })
);
}
async processAsyncPlugin(promise, params) {
const plugin = await promise;
const state = this.view.state.reconfigure({
plugins: [...this.view.state.plugins, this.processPlugin(plugin, params)],
});
this.view.updateState(state);
}
processPlugin(plugin, params) {
if (typeof plugin === "function") {
const ret = plugin(params);
if (ret instanceof Promise) {
this.processAsyncPlugin(ret, params);
return;
}
return this.processPlugin(ret, params);
}
if (plugin instanceof Array) {
return plugin.map((plugin) => this.processPlugin(plugin, params));
}
if (plugin instanceof Plugin) {
return plugin;
}
return new Plugin(plugin);
} }
<template> <template>
<div <div
id={{this.editorContainerId}} class="ProseMirror-container"
class="d-editor__container"
{{didInsert this.setup}} {{didInsert this.setup}}
{{didUpdate this.convertFromValue @value}}
{{didUpdate this.updateContext "placeholder" @placeholder}}
{{willDestroy this.teardown}} {{willDestroy this.teardown}}
> >
</div> </div>

View File

@ -1,12 +1,11 @@
import { common, createLowlight } from "lowlight"; import { highlightPlugin } from "prosemirror-highlightjs";
import { ensureHighlightJs } from "discourse/lib/highlight-syntax";
// cached hljs instance with custom plugins/languages
let hljs;
class CodeBlockWithLangSelectorNodeView { class CodeBlockWithLangSelectorNodeView {
changeListener = (e) => #selectAdded = false;
this.view.dispatch(
this.view.state.tr.setNodeMarkup(this.getPos(), null, {
params: e.target.value,
})
);
constructor(node, view, getPos) { constructor(node, view, getPos) {
this.node = node; this.node = node;
@ -16,77 +15,114 @@ class CodeBlockWithLangSelectorNodeView {
const code = document.createElement("code"); const code = document.createElement("code");
const pre = document.createElement("pre"); const pre = document.createElement("pre");
pre.appendChild(code); pre.appendChild(code);
pre.classList.add("d-editor__code-block"); pre.classList.add("code-block");
pre.appendChild(this.buildSelect());
this.dom = pre; this.dom = pre;
this.contentDOM = code; this.contentDOM = code;
this.appendSelect();
} }
buildSelect() { changeListener(e) {
this.view.dispatch(
this.view.state.tr.setNodeMarkup(this.getPos(), null, {
params: e.target.value,
})
);
if (e.target.firstChild.textContent) {
e.target.firstChild.textContent = "";
}
}
appendSelect() {
if (!hljs || this.#selectAdded) {
return;
}
this.#selectAdded = true;
const select = document.createElement("select"); const select = document.createElement("select");
select.contentEditable = false; select.contentEditable = false;
select.addEventListener("change", this.changeListener); select.addEventListener("change", (e) => this.changeListener(e));
select.classList.add("d-editor__code-lang-select"); select.classList.add("code-language-select");
const languages = hljs.listLanguages();
const empty = document.createElement("option"); const empty = document.createElement("option");
empty.textContent = ""; empty.textContent = languages.includes(this.node.attrs.params)
? ""
: this.node.attrs.params;
select.appendChild(empty); select.appendChild(empty);
createLowlight(common) languages.forEach((lang) => {
.listLanguages() const option = document.createElement("option");
.forEach((lang) => { option.textContent = lang;
const option = document.createElement("option"); option.selected = lang === this.node.attrs.params;
option.textContent = lang; select.appendChild(option);
option.selected = lang === this.node.attrs.params; });
select.appendChild(option);
});
return select; this.dom.appendChild(select);
} }
update(node) { update(node) {
this.appendSelect();
return node.type === this.node.type; return node.type === this.node.type;
} }
destroy() { destroy() {
this.dom.removeEventListener("change", this.changeListener); this.dom.removeEventListener("change", (e) => this.changeListener(e));
} }
} }
export default { /** @type {RichEditorExtension} */
const extension = {
nodeViews: { code_block: CodeBlockWithLangSelectorNodeView }, nodeViews: { code_block: CodeBlockWithLangSelectorNodeView },
plugins: { plugins({ pmState: { Plugin }, getContext }) {
props: { return [
// Handles removal of the code_block when it's at the start of the document async () =>
handleKeyDown(view, event) { highlightPlugin(
if ( (hljs = await ensureHighlightJs(
event.key === "Backspace" && getContext().session.highlightJsPath
view.state.selection.$from.parent.type === )),
view.state.schema.nodes.code_block && ["code_block", "html_block"]
view.state.selection.$from.start() === 1 && ),
view.state.selection.$from.parentOffset === 0 new Plugin({
) { props: {
const { tr } = view.state; // Handles removal of the code_block when it's at the start of the document
handleKeyDown(view, event) {
if (
event.key === "Backspace" &&
view.state.selection.$from.parent.type ===
view.state.schema.nodes.code_block &&
view.state.selection.$from.start() === 1 &&
view.state.selection.$from.parentOffset === 0
) {
const { tr } = view.state;
const codeBlock = view.state.selection.$from.parent; const codeBlock = view.state.selection.$from.parent;
const paragraph = view.state.schema.nodes.paragraph.create( const paragraph = view.state.schema.nodes.paragraph.create(
null, null,
codeBlock.content codeBlock.content
); );
tr.replaceWith( tr.replaceWith(
view.state.selection.$from.before(), view.state.selection.$from.before(),
view.state.selection.$from.after(), view.state.selection.$from.after(),
paragraph paragraph
); );
tr.setSelection( tr.setSelection(
new view.state.selection.constructor(tr.doc.resolve(1)) new view.state.selection.constructor(tr.doc.resolve(1))
); );
view.dispatch(tr); view.dispatch(tr);
return true; return true;
} }
}, },
}, },
}),
];
}, },
}; };
export default extension;

View File

@ -1,60 +0,0 @@
import { common, createLowlight } from "lowlight";
class CodeBlockWithLangSelectorNodeView {
constructor(node, view, getPos) {
this.node = node;
this.view = view;
this.getPos = getPos;
this.dom = document.createElement("div");
this.dom.style.position = "relative";
const select = document.createElement("select");
select.addEventListener("change", (e) =>
this.view.dispatch(
this.view.state.tr.setNodeMarkup(this.getPos(), null, {
params: e.target.value,
})
)
);
select.classList.add("d-editor__code-lang-select");
const empty = document.createElement("option");
empty.textContent = "";
select.appendChild(empty);
createLowlight(common)
.listLanguages()
.forEach((lang) => {
const option = document.createElement("option");
option.textContent = lang;
option.selected = lang === node.attrs.params;
select.appendChild(option);
});
this.dom.appendChild(select);
// TODO(renato): leaving with the keyboard to before the node doesn't work
const code = document.createElement("code");
this.dom.appendChild(document.createElement("pre")).appendChild(code);
this.contentDOM = code;
}
update(node) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
return true;
}
ignoreMutation() {
return true;
}
}
export default {
nodeViews: { code_block: CodeBlockWithLangSelectorNodeView },
};

View File

@ -1,8 +1,11 @@
import { buildEmojiUrl, emojiExists, isCustomEmoji } from "pretty-text/emoji"; import { buildEmojiUrl, emojiExists, isCustomEmoji } from "pretty-text/emoji";
import { translations } from "pretty-text/emoji/data";
import { emojiOptions } from "discourse/lib/text"; import { emojiOptions } from "discourse/lib/text";
import { isBoundary } from "discourse/static/prosemirror/lib/markdown-it";
import escapeRegExp from "discourse-common/utils/escape-regexp";
// TODO(renato): we need to avoid the invalid text:emoji: state (reminder to use isPunctChar to avoid deleting the space) /** @type {RichEditorExtension} */
export default { const extension = {
nodeSpec: { nodeSpec: {
emoji: { emoji: {
attrs: { code: {} }, attrs: { code: {} },
@ -52,6 +55,18 @@ export default {
}, },
options: { undoable: false }, options: { undoable: false },
}, },
{
match: new RegExp(
`(?<=^|\\W)(${Object.keys(translations).map(escapeRegExp).join("|")})$`
),
handler: (state, match, start, end) => {
return state.tr.replaceWith(
start,
end,
state.schema.nodes.emoji.create({ code: translations[match[1]] })
);
},
},
], ],
parse: { parse: {
@ -64,8 +79,14 @@ export default {
}, },
serializeNode: { serializeNode: {
emoji: (state, node) => { emoji(state, node) {
if (!isBoundary(state.out, state.out.length - 1)) {
state.write(" ");
}
state.write(`:${node.attrs.code}:`); state.write(`:${node.attrs.code}:`);
}, },
}, },
}; };
export default extension;

View File

@ -1,4 +1,7 @@
export default { import { isBoundary } from "discourse/static/prosemirror/lib/markdown-it";
/** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
hashtag: { hashtag: {
attrs: { name: {} }, attrs: { name: {} },
@ -30,7 +33,7 @@ export default {
inputRules: [ inputRules: [
{ {
match: /(?<=^|\W)#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101}) $/, match: /(?<=^|\W)#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101})\s$/,
handler: (state, match, start, end) => handler: (state, match, start, end) =>
state.selection.$from.nodeBefore?.type !== state.schema.nodes.hashtag && state.selection.$from.nodeBefore?.type !== state.schema.nodes.hashtag &&
state.tr.replaceWith(start, end, [ state.tr.replaceWith(start, end, [
@ -42,7 +45,7 @@ export default {
], ],
parse: { parse: {
span: (state, token, tokens, i) => { span(state, token, tokens, i) {
if (token.attrGet("class") === "hashtag-raw") { if (token.attrGet("class") === "hashtag-raw") {
state.openNode(state.schema.nodes.hashtag, { state.openNode(state.schema.nodes.hashtag, {
name: tokens[i + 1].content.slice(1), name: tokens[i + 1].content.slice(1),
@ -53,8 +56,20 @@ export default {
}, },
serializeNode: { serializeNode: {
hashtag: (state, node) => { hashtag(state, node, parent, index) {
if (!isBoundary(state.out, state.out.length - 1)) {
state.write(" ");
}
state.write(`#${node.attrs.name}`); state.write(`#${node.attrs.name}`);
const nextSibling =
parent.childCount > index + 1 ? parent.child(index + 1) : null;
if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
state.write(" ");
}
}, },
}, },
}; };
export default extension;

View File

@ -1,4 +1,5 @@
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
heading: { heading: {
attrs: { level: { default: 1 } }, attrs: { level: { default: 1 } },
@ -20,3 +21,5 @@ export default {
}, },
}, },
}; };
export default extension;

View File

@ -1,6 +1,35 @@
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
// TODO(renato): html_block should be like a passthrough code block html_block: {
html_block: { block: "paragraph", noCloseToken: true }, attrs: { params: { default: "html" } },
group: "block",
content: "text*",
code: true,
defining: true,
marks: "",
isolating: true,
selectable: true,
draggable: true,
parseDOM: [{ tag: "pre.html-block", preserveWhitespace: "full" }],
toDOM() {
return ["pre", { class: "html-block" }, ["code", 0]];
},
},
},
parse: {
html_block: (state, token) => {
state.openNode(state.schema.nodes.html_block);
state.addText(token.content.trim());
state.closeNode();
},
},
serializeNode: {
html_block: (state, node) => {
state.renderContent(node);
state.write("\n\n");
},
}, },
}; };
export default extension;

View File

@ -21,13 +21,15 @@ const ALLOWED_INLINE = [
const ALL_ALLOWED_TAGS = [...Object.keys(HTML_INLINE_MARKS), ...ALLOWED_INLINE]; const ALL_ALLOWED_TAGS = [...Object.keys(HTML_INLINE_MARKS), ...ALLOWED_INLINE];
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
// TODO(renato): this node is hard to get past when at the end of a block // TODO(renato): this node is hard to get past when at the end of a block
// and is added to a newline unintentionally, investigate // and is added to a newline unintentionally, investigate
html_inline: { html_inline: {
group: "inline", group: "inline",
inline: true, inline: true,
isolating: true,
content: "inline*", content: "inline*",
attrs: { tag: {} }, attrs: { tag: {} },
parseDOM: ALLOWED_INLINE.map((tag) => ({ tag })), parseDOM: ALLOWED_INLINE.map((tag) => ({ tag })),
@ -37,8 +39,8 @@ export default {
parse: { parse: {
// TODO(renato): it breaks if it's missing an end tag // TODO(renato): it breaks if it's missing an end tag
html_inline: (state, token) => { html_inline: (state, token) => {
const openMatch = token.content.match(/^<([a-z]+)>$/u); const openMatch = token.content.match(/^<([a-z]+)>$/);
const closeMatch = token.content.match(/^<\/([a-z]+)>$/u); const closeMatch = token.content.match(/^<\/([a-z]+)>$/);
if (openMatch) { if (openMatch) {
const tagName = openMatch[1]; const tagName = openMatch[1];
@ -49,7 +51,7 @@ export default {
} }
if (ALLOWED_INLINE.includes(tagName)) { if (ALLOWED_INLINE.includes(tagName)) {
state.openNode(state.schema.nodeType.html_inline, { state.openNode(state.schema.nodes.html_inline, {
tag: tagName, tag: tagName,
}); });
} }
@ -113,3 +115,5 @@ export default {
}, },
}, },
}; };
export default extension;

View File

@ -10,7 +10,8 @@ const PLACEHOLDER_IMG = "/images/transparent.png";
const ALT_TEXT_REGEX = const ALT_TEXT_REGEX =
/^(.*?)(?:\|(\d{1,4}x\d{1,4}))?(?:,\s*(\d{1,3})%)?(?:\|(.*))?$/; /^(.*?)(?:\|(\d{1,4}x\d{1,4}))?(?:,\s*(\d{1,3})%)?(?:\|(.*))?$/;
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
image: { image: {
inline: true, inline: true,
@ -106,7 +107,7 @@ export default {
}, },
}, },
plugins: ({ Plugin }) => { plugins({ pmState: { Plugin } }) {
const shortUrlResolver = new Plugin({ const shortUrlResolver = new Plugin({
state: { state: {
init() { init() {
@ -115,6 +116,7 @@ export default {
apply(tr, value) { apply(tr, value) {
let updated = value.slice(); let updated = value.slice();
// we should only track the changes
tr.doc.descendants((node, pos) => { tr.doc.descendants((node, pos) => {
if (node.type.name === "image" && node.attrs["data-orig-src"]) { if (node.type.name === "image" && node.attrs["data-orig-src"]) {
if (node.attrs.src === PLACEHOLDER_IMG) { if (node.attrs.src === PLACEHOLDER_IMG) {
@ -140,7 +142,6 @@ export default {
const unresolvedUrls = shortUrlResolver.getState(view.state); const unresolvedUrls = shortUrlResolver.getState(view.state);
// Process only unresolved URLs
for (const unresolved of unresolvedUrls) { for (const unresolved of unresolvedUrls) {
const cachedUrl = lookupCachedUploadUrl(unresolved.src).url; const cachedUrl = lookupCachedUploadUrl(unresolved.src).url;
const url = const url =
@ -168,3 +169,5 @@ export default {
return shortUrlResolver; return shortUrlResolver;
}, },
}; };
export default extension;

View File

@ -19,7 +19,6 @@ import underline from "./underline";
const defaultExtensions = [ const defaultExtensions = [
emoji, emoji,
// image must be after emoji
image, image,
hashtag, hashtag,
mention, mention,
@ -27,16 +26,16 @@ const defaultExtensions = [
underline, underline,
htmlInline, htmlInline,
htmlBlock, htmlBlock,
onebox,
link, link,
heading, heading,
codeBlock, codeBlock,
quote, quote,
onebox,
trailingParagraph, trailingParagraph,
typographerReplacements, typographerReplacements,
markdownPaste, markdownPaste,
// table must be last // table last
table, table,
]; ];

View File

@ -1,25 +1,135 @@
export default { const markdownUrlInputRule = ({ schema, markInputRule }) =>
inputRules: [ markInputRule(
// []() replacement /\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/,
({ schema, markInputRule }) => schema.marks.link,
markInputRule( (match) => {
/\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/, return { href: match[2], title: match[3] };
schema.marks.link, }
(match) => { );
return { href: match[2], title: match[3] };
/** @type {RichEditorExtension} */
const extension = {
markSpec: {
link: {
attrs: {
href: {},
title: { default: null },
autoLink: { default: null },
attachment: { default: null },
},
inclusive: false,
parseDOM: [
{
tag: "a[href]",
getAttrs(dom) {
return {
href: dom.getAttribute("href"),
title: dom.getAttribute("title"),
attachment: dom.classList.contains("attachment"),
};
},
},
],
toDOM(node) {
return [
"a",
{
href: node.attrs.href,
title: node.attrs.title,
class: node.attrs.attachment ? "attachment" : undefined,
},
0,
];
},
},
},
parse: {
link: {
mark: "link",
getAttrs(tok, tokens, i) {
const attachment = tokens[i + 1].content.endsWith("|attachment");
if (attachment) {
tokens[i + 1].content = tokens[i + 1].content.replace(
/\|attachment$/,
""
);
} }
), return {
// TODO(renato): auto-linkify when typing (https://github.com/markdown-it/markdown-it/blob/master/lib/rules_inline/autolink.mjs) href: tok.attrGet("href"),
], title: tok.attrGet("title") || null,
plugins: ({ Plugin, Slice, Fragment }) => autoLink: tok.markup === "autolink",
attachment,
};
},
},
},
inputRules: [markdownUrlInputRule],
plugins: ({
pmState: { Plugin },
pmModel: { Slice, Fragment },
pmTransform: {
ReplaceStep,
ReplaceAroundStep,
AddMarkStep,
RemoveMarkStep,
},
pmHistory: { undoDepth },
utils,
}) =>
new Plugin({ new Plugin({
// Auto-linkify typed URLs
appendTransaction: (transactions, prevState, state) => {
const isUndo = undoDepth(prevState) - undoDepth(state) === 1;
if (isUndo) {
return;
}
const docChanged = transactions.some(
(transaction) => transaction.docChanged
);
if (!docChanged) {
return;
}
const composedTransaction = utils.composeSteps(transactions, prevState);
const changes = utils.getChangedRanges(
composedTransaction,
[ReplaceAroundStep, ReplaceStep],
[AddMarkStep, ReplaceAroundStep, ReplaceStep, RemoveMarkStep]
);
const { mapping } = composedTransaction;
const { tr, doc } = state;
for (const { prevFrom, prevTo, from, to } of changes) {
utils
.findTextBlocksInRange(doc, { from, to })
.forEach(({ text, positionStart }) => {
const matches = utils.getLinkify().match(text);
if (!matches) {
return;
}
for (const match of matches) {
const { index, lastIndex, raw } = match;
const start = positionStart + index;
const end = positionStart + lastIndex + 1;
const href = raw;
// TODO not ready yet
// tr.setMeta("autolinking", true).addMark(
// start,
// end,
// state.schema.marks.link.create({ href })
// );
}
});
}
return tr;
},
props: { props: {
// Auto-linkify plain-text pasted URLs // Auto-linkify plain-text pasted URLs
// TODO(renato): URLs copied from HTML will go through the regular HTML parsing
// it would be nice to auto-linkify them too
clipboardTextParser(text, $context, plain, view) { clipboardTextParser(text, $context, plain, view) {
// TODO(renato): a less naive regex, reuse existing if (view.state.selection.empty || !utils.getLinkify().test(text)) {
if (!text.match(/^https?:\/\//) || view.state.selection.empty) {
return; return;
} }
@ -34,6 +144,46 @@ export default {
]); ]);
return new Slice(Fragment.from(textNode), 0, 0); return new Slice(Fragment.from(textNode), 0, 0);
}, },
// Auto-linkify rich content with a single text node that is a URL
transformPasted(paste, view) {
if (
paste.content.childCount === 1 &&
paste.content.firstChild.isText &&
!paste.content.firstChild.marks.some(
(mark) => mark.type.name === "link"
)
) {
const matches = utils
.getLinkify()
.match(paste.content.firstChild.text);
const isFullMatch =
matches &&
matches.length === 1 &&
matches[0].raw === paste.content.firstChild.text;
if (!isFullMatch) {
return paste;
}
const marks = view.state.selection.$head.marks();
const originalText = view.state.doc.textBetween(
view.state.selection.from,
view.state.selection.to
);
const textNode = view.state.schema.text(originalText, [
...marks,
view.state.schema.marks.link.create({
href: paste.content.firstChild.text,
}),
]);
paste = new Slice(Fragment.from(textNode), 0, 0);
}
return paste;
},
}, },
}), }),
}; };
export default extension;

View File

@ -1,7 +1,8 @@
import { convertFromMarkdown } from "../lib/parser"; import { convertFromMarkdown } from "../lib/parser";
export default { /** @type {RichEditorExtension} */
plugins({ Plugin, Fragment, Slice }) { const extension = {
plugins({ pmState: { Plugin }, pmModel: { Fragment, Slice } }) {
return new Plugin({ return new Plugin({
props: { props: {
clipboardTextParser(text, $context, plain, view) { clipboardTextParser(text, $context, plain, view) {
@ -13,3 +14,5 @@ export default {
}); });
}, },
}; };
export default extension;

View File

@ -1,8 +1,8 @@
// TODO(renato): similar to emoji, avoid joining anything@mentions, as it's invalid markdown
import { mentionRegex } from "pretty-text/mentions"; import { mentionRegex } from "pretty-text/mentions";
import { isBoundary } from "discourse/static/prosemirror/lib/markdown-it";
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
mention: { mention: {
attrs: { name: {} }, attrs: { name: {} },
@ -58,8 +58,20 @@ export default {
}, },
serializeNode: { serializeNode: {
mention: (state, node) => { mention: (state, node, parent, index) => {
if (!isBoundary(state.out, state.out.length - 1)) {
state.write(" ");
}
state.write(`@${node.attrs.name}`); state.write(`@${node.attrs.name}`);
const nextSibling =
parent.childCount > index + 1 ? parent.child(index + 1) : null;
if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
state.write(" ");
}
}, },
}, },
}; };
export default extension;

View File

@ -1,75 +1,228 @@
import { cachedInlineOnebox } from "pretty-text/inline-oneboxer"; import {
applyCachedInlineOnebox,
cachedInlineOnebox,
} from "pretty-text/inline-oneboxer";
import { addToLoadingQueue, loadNext } from "pretty-text/oneboxer";
import { lookupCache } from "pretty-text/oneboxer-cache"; import { lookupCache } from "pretty-text/oneboxer-cache";
import { ajax } from "discourse/lib/ajax";
import { isBoundary } from "discourse/static/prosemirror/lib/markdown-it";
import escapeRegExp from "discourse-common/utils/escape-regexp";
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
onebox: { onebox: {
attrs: { url: {}, html: {} }, attrs: { url: {}, html: {} },
selectable: false, selectable: true,
group: "inline", group: "block",
inline: true,
atom: true, atom: true,
draggable: true, draggable: true,
parseDOM: [ parseDOM: [
{
tag: "div.onebox-wrapper",
getAttrs(dom) {
return { url: dom.dataset.oneboxSrc, html: dom.innerHTML };
},
},
{ {
tag: "aside.onebox", tag: "aside.onebox",
getAttrs(dom) { getAttrs(dom) {
return { url: dom["data-onebox-src"], html: dom.outerHTML }; return { url: dom.dataset.oneboxSrc, html: dom.outerHTML };
}, },
}, },
], ],
toDOM(node) { toDOM(node) {
// const dom = document.createElement("aside"); const dom = document.createElement("div");
// dom.outerHTML = node.attrs.html; dom.dataset.oneboxSrc = node.attrs.url;
dom.classList.add("onebox-wrapper");
// TODO(renato): revisit? dom.innerHTML = node.attrs.html;
return new DOMParser().parseFromString(node.attrs.html, "text/html") return dom;
.body.firstChild; },
},
onebox_inline: {
attrs: { url: {}, title: {} },
inline: true,
group: "inline",
selectable: true,
atom: true,
draggable: true,
parseDOM: [
{
// TODO link marks are still processed before this when pasting
tag: "a.inline-onebox",
getAttrs(dom) {
return { url: dom.getAttribute("href"), title: dom.textContent };
},
priority: 60,
},
],
toDOM(node) {
return [
"a",
{
class: "inline-onebox",
href: node.attrs.url,
contentEditable: false,
},
node.attrs.title,
];
}, },
}, },
}, },
serializeNode: { serializeNode: {
onebox(state, node) { onebox(state, node) {
state.write(node.attrs.url); state.ensureNewLine();
state.write(`${node.attrs.url}\n\n`);
},
onebox_inline(state, node, parent, index) {
if (!isBoundary(state.out, state.out.length - 1)) {
state.write(" ");
}
state.text(node.attrs.url);
const nextSibling =
parent.childCount > index + 1 ? parent.child(index + 1) : null;
// TODO(renato): differently from emoji/hashtag, some few punct chars
// we don't want to join, like -#%/:@
if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
state.write(" ");
}
}, },
}, },
plugins: ({ Plugin }) => { plugins({ pmState: { Plugin }, pmTransform: { ReplaceStep } }) {
const plugin = new Plugin({ const plugin = new Plugin({
appendTransaction(transactions, prevState, state) {
const tr = state.tr;
for (const transaction of transactions) {
const replaceSteps = transaction.steps.filter(
(step) => step instanceof ReplaceStep
);
for (const [index, step] of replaceSteps.entries()) {
const map = transaction.mapping.maps[index];
const [start, oldSize, newSize] = map.ranges;
// if any onebox_inline moved position to be close to a text node
}
}
// Return the transaction if any changes were made
return tr.docChanged ? tr : null;
},
state: { state: {
init() { init() {
return []; return { full: {}, inline: {} };
}, },
apply(tr, value) { apply(tr, value) {
// TODO(renato) const updated = { full: [], inline: [] };
return value;
// we shouldn't check all descendants, but only the ones that have changed
// it's a problem in other plugins too where we need to optimize
tr.doc.descendants((node, pos) => {
// if node has the link mark
const link = node.marks.find((mark) => mark.type.name === "link");
if (
!tr.getMeta("autolinking") &&
!link?.attrs.autoLink &&
link?.attrs.href === node.textContent
) {
const resolvedPos = tr.doc.resolve(pos);
const isAtRoot = resolvedPos.depth === 1;
const parent = resolvedPos.parent;
const index = resolvedPos.index();
const prev = index > 0 ? parent.child(index - 1) : null;
const next =
index < parent.childCount - 1 ? parent.child(index + 1) : null;
const isAlone =
(!prev || prev.type.name === "hard_break") &&
(!next || next.type.name === "hard_break");
const isInline = !isAtRoot || !isAlone;
const obj = isInline ? updated.inline : updated.full;
obj[node.textContent] ??= [];
obj[node.textContent].push({
pos,
addToHistory: tr.getMeta("addToHistory"),
});
}
});
return updated;
}, },
}, },
view() { view() {
return { return {
update(view, prevState) { async update(view, prevState) {
if (prevState.doc.eq(view.state.doc)) { if (prevState.doc.eq(view.state.doc)) {
return; return;
} }
// console.log("discourse", view.props.discourse); const { full, inline } = plugin.getState(view.state);
const unresolvedLinks = plugin.getState(view.state); for (const [url, list] of Object.entries(full)) {
const html = await loadFullOnebox(url, view.props.getContext());
// console.log(unresolvedLinks); // naive check that this is not a <a href="url">url</a> onebox response
if (
for (const unresolved of unresolvedLinks) { new RegExp(
const isInline = unresolved.isInline; `<a href=["']${escapeRegExp(url)}["'].*>${escapeRegExp(
// console.log(isInline, cachedInlineOnebox(unresolved.text)); url
)}</a>`
const className = isInline ).test(html)
? "onebox-loading" ) {
: "inline-onebox-loading"; continue;
if (!isInline) {
// console.log(lookupCache(unresolved.text));
} }
for (const { pos, addToHistory } of list.sort(
(a, b) => b.pos - a.pos
)) {
const tr = view.state.tr;
console.log("replacing", pos, url);
const node = tr.doc.nodeAt(pos);
tr.replaceWith(
pos - 1,
pos + node.nodeSize,
view.state.schema.nodes.onebox.create({ url, html })
);
tr.setMeta("addToHistory", addToHistory);
view.dispatch(tr);
}
}
const inlineOneboxes = await loadInlineOneboxes(
Object.keys(inline),
view.props.getContext()
);
const tr = view.state.tr;
for (const [url, onebox] of Object.entries(inlineOneboxes)) {
for (const { pos, addToHistory } of inline[url]) {
const newPos = tr.mapping.map(pos);
const node = tr.doc.nodeAt(newPos);
tr.replaceWith(
newPos,
newPos + node.nodeSize,
view.state.schema.nodes.onebox_inline.create({
url,
title: onebox.title,
})
);
if (addToHistory !== undefined) {
tr.setMeta("addToHistory", addToHistory);
}
}
}
if (tr.docChanged) {
view.dispatch(tr);
} }
}, },
}; };
@ -80,18 +233,47 @@ export default {
}, },
}; };
function isValidUrl(text) { async function loadInlineOneboxes(urls, { categoryId, topicId }) {
try { const allOneboxes = {};
new URL(text); // If it can be parsed as a URL, it's valid.
return true; const uncachedUrls = [];
} catch { for (const url of urls) {
return false; const cached = cachedInlineOnebox(url);
if (cached) {
allOneboxes[url] = cached;
} else {
uncachedUrls.push(url);
}
} }
if (uncachedUrls.length === 0) {
return allOneboxes;
}
const { "inline-oneboxes": oneboxes } = await ajax("/inline-onebox", {
data: { urls: uncachedUrls, categoryId, topicId },
});
oneboxes.forEach((onebox) => {
if (onebox.title) {
applyCachedInlineOnebox(onebox.url, onebox);
allOneboxes[onebox.url] = onebox;
}
});
return allOneboxes;
} }
function isNodeInline(state, pos) { async function loadFullOnebox(url, { categoryId, topicId }) {
const resolvedPos = state.doc.resolve(pos); const cached = lookupCache(url);
const parent = resolvedPos.parent; if (cached) {
return cached;
}
return parent.childCount !== 1; return new Promise((onResolve) => {
addToLoadingQueue({ url, categoryId, topicId, onResolve });
loadNext(ajax);
});
} }
export default extension;

View File

@ -1,10 +1,12 @@
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
quote: { quote: {
content: "block+", content: "block+",
group: "block", group: "block",
defining: true,
inline: false, inline: false,
selectable: true,
isolating: true,
attrs: { attrs: {
username: {}, username: {},
postNumber: { default: null }, postNumber: { default: null },
@ -14,6 +16,7 @@ export default {
parseDOM: [ parseDOM: [
{ {
tag: "aside.quote", tag: "aside.quote",
contentElement: "blockquote",
getAttrs(dom) { getAttrs(dom) {
return { return {
username: dom.getAttribute("data-username"), username: dom.getAttribute("data-username"),
@ -32,25 +35,22 @@ export default {
attrs["data-topic"] = topicId; attrs["data-topic"] = topicId;
attrs["data-full"] = full ? "true" : "false"; attrs["data-full"] = full ? "true" : "false";
return ["aside", attrs, 0]; const quoteTitle = ["div", { class: "title" }, `${username}:`];
},
}, return ["aside", attrs, quoteTitle, ["blockquote", 0]];
quote_title: {
content: "inline*",
group: "block",
inline: false,
parseDOM: [{ tag: "aside[data-username] > div.title" }],
atom: true,
draggable: false,
selectable: false,
toDOM() {
return ["div", { class: "title" }, 0];
}, },
}, },
}, },
parse: { parse: {
quote_header: { block: "quote_title" }, quote_header_open(state, token, tokens, i) {
// removing the text child, this depends on the current token order:
// quote_header_open quote_controls_open quote_controls_close text quote_header_close
// otherwise it's hard to get a "quote_title" node to behave the way we need
// (a contentEditable=false node breaks the keyboard nav, among other issues)
tokens[i + 3].content = "";
},
quote_header_close() {},
quote_controls: { ignore: true }, quote_controls: { ignore: true },
bbcode(state, token) { bbcode(state, token) {
if (token.tag === "aside") { if (token.tag === "aside") {
@ -62,11 +62,6 @@ export default {
}); });
return true; return true;
} }
if (token.tag === "blockquote") {
state.openNode(state.schema.nodes.blockquote);
return true;
}
}, },
}, },
@ -78,13 +73,30 @@ export default {
const topicId = node.attrs.topicId ? `, topic:${node.attrs.topicId}` : ""; const topicId = node.attrs.topicId ? `, topic:${node.attrs.topicId}` : "";
state.write(`[quote="${node.attrs.username}${postNumber}${topicId}"]\n`); state.write(`[quote="${node.attrs.username}${postNumber}${topicId}"]\n`);
node.forEach((n) => { state.renderContent(node);
if (n.type.name === "blockquote") {
state.renderContent(n);
}
});
state.write("[/quote]\n\n"); state.write("[/quote]\n\n");
}, },
quote_title() {}, },
plugins({ pmState: { Plugin, NodeSelection } }) {
return new Plugin({
props: {
handleClickOn(view, pos, node, nodePos, event) {
if (
node.type.name === "quote" &&
event.target.classList.contains("title")
) {
view.dispatch(
view.state.tr.setSelection(
NodeSelection.create(view.state.doc, nodePos)
)
);
return true;
}
},
},
});
}, },
}; };
export default extension;

View File

@ -1,4 +1,5 @@
export default { /** @type {RichEditorExtension} */
const extension = {
markSpec: { markSpec: {
strikethrough: { strikethrough: {
parseDOM: [ parseDOM: [
@ -30,3 +31,5 @@ export default {
}, },
}, },
}; };
export default extension;

View File

@ -10,13 +10,16 @@
// | git status | git status | git status | // | git status | git status | git status |
// | git diff | git diff | git diff | // | git diff | git diff | git diff |
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
table: { table: {
content: "table_head table_body", content: "table_head table_body",
group: "block", group: "block",
tableRole: "table", tableRole: "table",
isolating: true, isolating: true,
selectable: true,
draggable: true,
parseDOM: [{ tag: "table" }], parseDOM: [{ tag: "table" }],
toDOM() { toDOM() {
return ["table", 0]; return ["table", 0];
@ -32,7 +35,7 @@ export default {
}, },
}, },
table_body: { table_body: {
content: "table_row*", content: "table_row+",
tableRole: "body", tableRole: "body",
isolating: true, isolating: true,
parseDOM: [{ tag: "tbody" }], parseDOM: [{ tag: "tbody" }],
@ -41,7 +44,7 @@ export default {
}, },
}, },
table_row: { table_row: {
content: "(table_header_cell | table_cell)*", content: "(table_cell | table_header_cell)+",
tableRole: "row", tableRole: "row",
parseDOM: [{ tag: "tr" }], parseDOM: [{ tag: "tr" }],
toDOM() { toDOM() {
@ -124,10 +127,13 @@ export default {
table(state, node) { table(state, node) {
state.flushClose(1); state.flushClose(1);
let headerBuffer = state.delim && state.atBlank() ? state.delim : ""; let headerBuffer = state.delim;
const prevInTable = state.inTable; const prevInTable = state.inTable;
state.inTable = true; state.inTable = true;
// leading newline, it seems to have issues in a line just below a > blockquote otherwise
state.out += "\n";
// group is table_head or table_body // group is table_head or table_body
node.forEach((group, groupOffset, groupIndex) => { node.forEach((group, groupOffset, groupIndex) => {
group.forEach((row) => { group.forEach((row) => {
@ -182,3 +188,5 @@ export default {
table_cell() {}, table_cell() {},
}, },
}; };
export default extension;

View File

@ -1,5 +1,6 @@
export default { /** @type {RichEditorExtension} */
plugins({ Plugin, PluginKey }) { const extension = {
plugins({ pmState: { Plugin, PluginKey } }) {
const plugin = new PluginKey("trailing-paragraph"); const plugin = new PluginKey("trailing-paragraph");
return new Plugin({ return new Plugin({
@ -16,16 +17,29 @@ export default {
}, },
state: { state: {
init(_, state) { init(_, state) {
return state.doc.lastChild.type !== state.schema.nodes.paragraph; return !isLastChildEmptyParagraph(state);
}, },
apply(tr, value) { apply(tr, value) {
if (!tr.docChanged) { if (!tr.docChanged) {
return value; return value;
} }
return tr.doc.lastChild.type !== tr.doc.type.schema.nodes.paragraph; return !isLastChildEmptyParagraph(tr);
}, },
}, },
}); });
}, },
}; };
function isLastChildEmptyParagraph(state) {
const { doc } = state;
const lastChild = doc.lastChild;
return (
lastChild.type.name === "paragraph" &&
lastChild.nodeSize === 2 &&
lastChild.content.size === 0
);
}
export default extension;

View File

@ -7,7 +7,8 @@ import {
// TODO(renato): should respect `enable_markdown_typographer` // TODO(renato): should respect `enable_markdown_typographer`
export default { /** @type {RichEditorExtension} */
const extension = {
inputRules: [ inputRules: [
{ {
match: new RegExp(`(${RARE_RE.source})$`), match: new RegExp(`(${RARE_RE.source})$`),
@ -31,3 +32,5 @@ export default {
}, },
], ],
}; };
export default extension;

View File

@ -1,4 +1,5 @@
export default { /** @type {RichEditorExtension} */
const extension = {
markSpec: { markSpec: {
underline: { underline: {
toDOM() { toDOM() {
@ -21,3 +22,5 @@ export default {
}, },
}, },
}; };
export default extension;

View File

@ -0,0 +1,26 @@
import { buildEngine } from "discourse/static/markdown-it";
import loadPluginFeatures from "discourse/static/markdown-it/features";
import defaultFeatures from "discourse-markdown-it/features/index";
let engine;
function getEngine() {
engine ??= buildEngine({
featuresOverride: [...defaultFeatures, ...loadPluginFeatures()]
.map(({ id }) => id)
// Avoid oneboxing when parsing, we'll handle that separately
.filter((id) => id !== "onebox"),
});
return engine;
}
export const parse = (text) => getEngine().parse(text);
export const getLinkify = () => getEngine().linkify;
export const isBoundary = (str, index) =>
getEngine().options.engine.utils.isWhiteSpace(str.charCodeAt(index)) ||
getEngine().options.engine.utils.isPunctChar(
String.fromCharCode(str.charCodeAt(index))
);

View File

@ -1,8 +1,6 @@
import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown"; import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown";
import { getParsers } from "discourse/lib/composer/rich-editor-extensions"; import { getParsers } from "discourse/lib/composer/rich-editor-extensions";
import { parse as markdownItParse } from "discourse/static/markdown-it"; import { parse } from "./markdown-it";
import loadPluginFeatures from "discourse/static/markdown-it/features";
import defaultFeatures from "discourse-markdown-it/features/index";
// TODO(renato): We need a workaround for this parsing issue: // TODO(renato): We need a workaround for this parsing issue:
// https://github.com/ProseMirror/prosemirror-markdown/issues/82 // https://github.com/ProseMirror/prosemirror-markdown/issues/82
@ -19,13 +17,14 @@ const postParseTokens = {
softbreak: (state) => state.addNode(state.schema.nodes.hard_break), softbreak: (state) => state.addNode(state.schema.nodes.hard_break),
}; };
let parseOptions; let initialized;
function initializeParser() { function ensureCustomParsers() {
if (parseOptions) { if (initialized) {
return; return;
} }
for (const [key, value] of Object.entries(getParsers())) { for (const [key, value] of Object.entries(getParsers())) {
// Not a ParseSpec
if (typeof value === "function") { if (typeof value === "function") {
postParseTokens[key] = value; postParseTokens[key] = value;
} else { } else {
@ -33,24 +32,20 @@ function initializeParser() {
} }
} }
const featuresOverride = [...defaultFeatures, ...loadPluginFeatures()] initialized = true;
.map(({ id }) => id)
// Avoid oneboxing when parsing, we'll handle that separately
.filter((id) => id !== "onebox");
parseOptions = { featuresOverride };
} }
export function convertFromMarkdown(schema, text) { export function convertFromMarkdown(schema, text) {
initializeParser(); ensureCustomParsers();
const tokens = markdownItParse(text, parseOptions); const tokens = parse(text);
console.log("Converting tokens", tokens); console.log("Converting tokens", tokens);
const dummyTokenizer = { parse: () => tokens }; const dummyTokenizer = { parse: () => tokens };
const parser = new MarkdownParser(schema, dummyTokenizer, parseTokens); const parser = new MarkdownParser(schema, dummyTokenizer, parseTokens);
// Adding function parse handlers directly
for (const [key, callback] of Object.entries(postParseTokens)) { for (const [key, callback] of Object.entries(postParseTokens)) {
parser.tokenHandlers[key] = callback; parser.tokenHandlers[key] = callback;
} }

View File

@ -0,0 +1,79 @@
export { getLinkify, isBoundary } from "../lib/markdown-it";
export function composeSteps(transactions, prevState) {
const { tr } = prevState;
transactions.forEach((transaction) => {
transaction.steps.forEach((step) => {
tr.step(step);
});
});
return tr;
}
export function getChangedRanges(tr, replaceTypes, rangeTypes) {
const ranges = [];
const { steps, mapping } = tr;
const inverseMapping = mapping.invert();
steps.forEach((step, i) => {
if (!isValidStep(step, replaceTypes)) {
return;
}
const rawRanges = [];
const stepMap = step.getMap();
const mappingSlice = mapping.slice(i);
if (stepMap.ranges.length === 0 && isValidStep(step, rangeTypes)) {
const { from, to } = step;
rawRanges.push({ from, to });
} else {
stepMap.forEach((from, to) => {
rawRanges.push({ from, to });
});
}
rawRanges.forEach((range) => {
const from = mappingSlice.map(range.from, -1);
const to = mappingSlice.map(range.to);
ranges.push({
from,
to,
prevFrom: inverseMapping.map(from, -1),
prevTo: inverseMapping.map(to),
});
});
});
return ranges.sort((a, z) => a.from - z.from);
}
export function isValidStep(step, types) {
return types.some((type) => step instanceof type);
}
export function findTextBlocksInRange(doc, range) {
const nodesWithPos = [];
// define a placeholder for leaf nodes to calculate link position
doc.nodesBetween(range.from, range.to, (node, pos) => {
if (!node.isTextblock || !node.type.allowsMarkType("link")) {
return;
}
nodesWithPos.push({ node, pos });
});
return nodesWithPos.map((textBlock) => ({
text: doc.textBetween(
textBlock.pos,
textBlock.pos + textBlock.node.nodeSize,
undefined,
" "
),
positionStart: textBlock.pos,
}));
}

View File

@ -1,12 +1,15 @@
import { setOwner } from "@ember/owner"; import { setOwner } from "@ember/owner";
import { next } from "@ember/runloop";
import $ from "jquery"; import $ from "jquery";
import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands"; import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
import { convertFromMarkdown } from "discourse/static/prosemirror/lib/parser"; import { convertFromMarkdown } from "discourse/static/prosemirror/lib/parser";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
export default class TextManipulation { /** @implements {TextManipulation} */
markdownOptions; export default class ProsemirrorTextManipulation {
allowPreview = false;
/** @type {import("prosemirror-model").Schema} */ /** @type {import("prosemirror-model").Schema} */
schema; schema;
/** @type {import("prosemirror-view").EditorView} */ /** @type {import("prosemirror-view").EditorView} */
@ -15,25 +18,17 @@ export default class TextManipulation {
placeholder; placeholder;
autocompleteHandler; autocompleteHandler;
constructor(owner, { markdownOptions, schema, view }) { constructor(owner, { schema, view }) {
setOwner(this, owner); setOwner(this, owner);
this.markdownOptions = markdownOptions;
this.schema = schema; this.schema = schema;
this.view = view; this.view = view;
this.$editorElement = $(view.dom); this.$editorElement = $(view.dom);
this.placeholder = new PlaceholderHandler({ schema, view }); this.placeholder = new ProsemirrorPlaceholderHandler({ schema, view });
this.autocompleteHandler = new AutocompleteHandler({ schema, view }); this.autocompleteHandler = new ProsemirrorAutocompleteHandler({
} schema,
view,
/** });
* The textual value of the selected text block
* @returns {string}
*/
get value() {
const parent = this.view.state.selection.$head.parent;
return parent.textBetween(0, parent.nodeSize - 2, " ", " ");
} }
getSelected(trimLeading, opts) { getSelected(trimLeading, opts) {
@ -58,15 +53,12 @@ export default class TextManipulation {
} }
putCursorAtEnd() { putCursorAtEnd() {
// this.view.dispatch( this.focus();
// this.view.state.tr.setSelection( next(() => (this.view.dom.scrollTop = this.view.dom.scrollHeight));
// TextSelection.create(this.view.state.doc, 0)
// )
// );
} }
autocomplete(options) { autocomplete(options) {
return this.$editorElement.autocomplete( this.$editorElement.autocomplete(
options instanceof Object options instanceof Object
? { textHandler: this.autocompleteHandler, ...options } ? { textHandler: this.autocompleteHandler, ...options }
: options : options
@ -102,7 +94,7 @@ export default class TextManipulation {
} }
addText(sel, text, options) { addText(sel, text, options) {
const doc = convertFromMarkdown(this.schema, text, this.markdownOptions); const doc = convertFromMarkdown(this.schema, text);
// assumes it returns a single block node // assumes it returns a single block node
const content = const content =
@ -194,7 +186,9 @@ export default class TextManipulation {
@bind @bind
emojiSelected(code) { emojiSelected(code) {
const text = this.value.slice(0, this.getCaretPosition()); const text = this.autocompleteHandler
.getValue()
.slice(0, this.getCaretPosition());
const captures = text.match(/\B:(\w*)$/); const captures = text.match(/\B:(\w*)$/);
if (!captures) { if (!captures) {
@ -262,9 +256,33 @@ export default class TextManipulation {
return $anchor.pos - $anchor.start(); return $anchor.pos - $anchor.start();
} }
indentSelection(direction) {
const command = direction === "right" ? wrapIn : lift;
command(this.schema.nodes.blockquote)(this.view.state, this.view.dispatch);
}
insertText(text) {
this.view.dispatch(
this.view.state.tr.insertText(text, this.view.state.selection.from)
);
}
replaceText(oldValue, newValue, opts) {
// this method should be deprecated, this is not very reliable:
// we're converting the current document to markdown, replacing it, and setting its result
// as the new document content
// TODO
}
toggleDirection() {
this.view.dom.dir = this.view.dom.dir === "rtl" ? "ltr" : "rtl";
}
} }
class AutocompleteHandler { /** @implements {AutocompleteHandler} */
class ProsemirrorAutocompleteHandler {
/** @type {import("prosemirror-view").EditorView} */ /** @type {import("prosemirror-view").EditorView} */
view; view;
/** @type {import("prosemirror-model").Schema} */ /** @type {import("prosemirror-model").Schema} */
@ -279,8 +297,11 @@ class AutocompleteHandler {
* The textual value of the selected text block * The textual value of the selected text block
* @returns {string} * @returns {string}
*/ */
get value() { getValue() {
return this.view.state.selection.$head.nodeBefore?.textContent ?? ""; return (
(this.view.state.selection.$head.nodeBefore?.textContent ?? "") +
(this.view.state.selection.$head.nodeAfter?.textContent ?? "") || " "
);
} }
/** /**
@ -292,7 +313,7 @@ class AutocompleteHandler {
* @param {number} end * @param {number} end
* @param {String} term * @param {String} term
*/ */
replaceTerm({ start, end, term }) { replaceTerm(start, end, term) {
const node = this.view.state.selection.$head.nodeBefore; const node = this.view.state.selection.$head.nodeBefore;
const from = this.view.state.selection.from - node.nodeSize + start; const from = this.view.state.selection.from - node.nodeSize + start;
const to = this.view.state.selection.from - node.nodeSize + end + 1; const to = this.view.state.selection.from - node.nodeSize + end + 1;
@ -339,13 +360,6 @@ class AutocompleteHandler {
return node.nodeSize; return node.nodeSize;
} }
/**
* Gets the caret coordinates within the selected text block
*
* @param {number} start
*
* @returns {{top: number, left: number}}
*/
getCaretCoords(start) { getCaretCoords(start) {
const node = this.view.state.selection.$head.nodeBefore; const node = this.view.state.selection.$head.nodeBefore;
const pos = this.view.state.selection.from - node.nodeSize + start; const pos = this.view.state.selection.from - node.nodeSize + start;
@ -359,7 +373,7 @@ class AutocompleteHandler {
}; };
} }
inCodeBlock() { async inCodeBlock() {
return ( return (
this.view.state.selection.$from.parent.type === this.view.state.selection.$from.parent.type ===
this.schema.nodes.code_block this.schema.nodes.code_block
@ -367,7 +381,8 @@ class AutocompleteHandler {
} }
} }
class PlaceholderHandler { /** @implements {PlaceholderHandler} */
class ProsemirrorPlaceholderHandler {
view; view;
schema; schema;

View File

@ -1,13 +0,0 @@
import { common, createLowlight } from "lowlight";
import { createHighlightPlugin } from "prosemirror-highlight";
import { createParser } from "prosemirror-highlight/lowlight";
export function createHighlight() {
const lowlight = createLowlight(common);
const parser = createParser(lowlight);
return createHighlightPlugin({
parser,
languageExtractor: (node) => node.attrs.params,
});
}

View File

@ -1,8 +1,6 @@
import { import {
chainCommands, chainCommands,
exitCode, exitCode,
joinDown,
joinUp,
lift, lift,
selectParentNode, selectParentNode,
setBlockType, setBlockType,
@ -48,8 +46,6 @@ export function buildKeymap(schema, initialKeymap = {}, suppressKeys) {
bind("Mod-y", redo); bind("Mod-y", redo);
} }
bind("Alt-ArrowUp", joinUp);
bind("Alt-ArrowDown", joinDown);
bind("Mod-BracketLeft", lift); bind("Mod-BracketLeft", lift);
bind("Escape", selectParentNode); bind("Escape", selectParentNode);

View File

@ -5,19 +5,38 @@ const isEmptyParagraph = (node) => {
return node.type.name === "paragraph" && node.nodeSize === 2; return node.type.name === "paragraph" && node.nodeSize === 2;
}; };
export default (placeholder) => { export default () => {
let placeholder;
return new Plugin({ return new Plugin({
view(view) {
placeholder = view.props.getContext().placeholder;
return {};
},
state: {
init() {
return placeholder;
},
apply(tr) {
const contextChanged = tr.getMeta("discourseContextChanged");
if (contextChanged?.key === "placeholder") {
placeholder = contextChanged.value;
}
return placeholder;
},
},
props: { props: {
decorations(state) { decorations(state) {
const { $from } = state.selection; const { $head } = state.selection;
if ( if (
state.doc.childCount === 1 && state.doc.childCount === 1 &&
state.doc.firstChild === $from.parent && state.doc.firstChild === $head.parent &&
isEmptyParagraph($from.parent) isEmptyParagraph($head.parent)
) { ) {
const decoration = Decoration.node($from.before(), $from.after(), { const decoration = Decoration.node($head.before(), $head.after(), {
"data-placeholder": placeholder, "data-placeholder": this.getState(state),
}); });
return DecorationSet.create(state.doc, [decoration]); return DecorationSet.create(state.doc, [decoration]);
} }

View File

@ -32,13 +32,12 @@
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"jspreadsheet-ce": "^4.15.0", "jspreadsheet-ce": "^4.15.0",
"lowlight": "^3.2.0",
"morphlex": "^0.0.16", "morphlex": "^0.0.16",
"pretty-text": "workspace:1.0.0", "pretty-text": "workspace:1.0.0",
"prosemirror-commands": "^1.6.0", "prosemirror-commands": "^1.6.0",
"prosemirror-dropcursor": "^1.8.1", "prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2", "prosemirror-gapcursor": "^1.3.2",
"prosemirror-highlight": "^0.11.0", "prosemirror-highlightjs": "^0.9.1",
"prosemirror-history": "^1.4.1", "prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0", "prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2", "prosemirror-keymap": "^1.2.2",
@ -46,6 +45,7 @@
"prosemirror-model": "^1.23.0", "prosemirror-model": "^1.23.0",
"prosemirror-schema-list": "^1.4.1", "prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.34.3" "prosemirror-view": "^1.34.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,43 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import ProsemirrorEditor from "discourse/static/prosemirror/components/prosemirror-editor";
import htmlBlock from "discourse/static/prosemirror/extensions/html-block";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { withPluginApi } from "discourse/lib/plugin-api";
module("Integration | Component | prosemirror-editor", function (hooks) {
setupRenderingTest(hooks);
test("renders the html-block", async function (assert) {
withPluginApi("1.40.0", (api) =>
api.registerRichEditorExtension(htmlBlock)
);
const value = `<div>
block1
# some markdown
</div>
<div>
block2
</div>
<div>
block3
</div>`;
await render(<template><ProsemirrorEditor @value={{value}} /></template>);
const editor = document.querySelector(".ProseMirror");
assert.dom("div > h1", editor).hasText("some markdown");
assert.dom("div:nth-of-type(1)", editor).hasTextContaining("block1");
assert.dom("div:nth-of-type(2)", editor).hasText("block2");
assert.dom("div:nth-of-type(3)", editor).hasText("block3");
});
});

View File

@ -0,0 +1,13 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import ProsemirrorEditor from "discourse/static/prosemirror/components/prosemirror-editor";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | prosemirror-editor", function (hooks) {
setupRenderingTest(hooks);
test("renders the editor", async function (assert) {
await render(<template><ProsemirrorEditor /></template>);
assert.dom(".ProseMirror").exists("it renders the ProseMirror editor");
});
});

View File

@ -54,7 +54,7 @@ function _handleLoadingOneboxImages() {
this.removeEventListener("load", _handleLoadingOneboxImages); this.removeEventListener("load", _handleLoadingOneboxImages);
} }
function loadNext(ajax) { export function loadNext(ajax) {
if (loadingQueue.length === 0) { if (loadingQueue.length === 0) {
timeout = null; timeout = null;
return; return;
@ -62,7 +62,8 @@ function loadNext(ajax) {
let timeoutMs = 150; let timeoutMs = 150;
let removeLoading = true; let removeLoading = true;
const { url, refresh, elem, categoryId, topicId } = loadingQueue.shift(); const { url, refresh, elem, categoryId, topicId, onResolve } =
loadingQueue.shift();
// Retrieve the onebox // Retrieve the onebox
return ajax("/onebox", { return ajax("/onebox", {
@ -78,6 +79,7 @@ function loadNext(ajax) {
(template) => { (template) => {
const node = domFromString(template)[0]; const node = domFromString(template)[0];
setLocalCache(normalize(url), node); setLocalCache(normalize(url), node);
onResolve?.(template);
elem.replaceWith(node); elem.replaceWith(node);
applySquareGenericOnebox(node); applySquareGenericOnebox(node);
}, },
@ -155,3 +157,17 @@ export function load({
timeout = timeout || discourseLater(() => loadNext(ajax), 150); timeout = timeout || discourseLater(() => loadNext(ajax), 150);
} }
} }
export function addToLoadingQueue({
url,
elem = {
replaceWith() {},
classList: { remove() {}, add() {} },
dataset: {},
},
categoryId,
topicId,
onResolve,
}) {
loadingQueue.push({ url, elem, categoryId, topicId, onResolve });
}

View File

@ -52,6 +52,12 @@ pre > code {
color: var(--hljs-number); color: var(--hljs-number);
} }
.hljs-tag,
.hljs-tag .hljs-title {
color: var(--hljs-tag);
font-weight: normal;
}
.hljs-string, .hljs-string,
.hljs-tag .hljs-string, .hljs-tag .hljs-string,
.hljs-template-tag, .hljs-template-tag,
@ -64,10 +70,6 @@ pre > code {
color: var(--hljs-title); color: var(--hljs-title);
} }
.hljs-name {
color: var(--hljs-name);
}
.hljs-quote, .hljs-quote,
.hljs-operator, .hljs-operator,
.hljs-selector-pseudo, .hljs-selector-pseudo,
@ -85,10 +87,8 @@ pre > code {
color: var(--hljs-title); color: var(--hljs-title);
} }
.hljs-tag, .hljs-name {
.hljs-tag .hljs-title { color: var(--hljs-name);
color: var(--hljs-tag);
font-weight: normal;
} }
.hljs-punctuation { .hljs-punctuation {

View File

@ -1,4 +1,4 @@
.d-editor__container { .ProseMirror-container {
margin: 0; margin: 0;
overscroll-behavior: contain; overscroll-behavior: contain;
overflow-anchor: none; overflow-anchor: none;
@ -9,11 +9,21 @@
height: 100%; height: 100%;
} }
.d-editor__editable { .ProseMirror {
outline: 0; outline: 0;
padding: 0 0.625rem; padding: 0 0.625rem;
a { > div:first-child,
> details:first-child {
// This is hacky, but helps having the leading gapcursor at the right position
&.ProseMirror-gapcursor {
position: relative;
display: block;
}
margin-top: 0.5rem;
}
a[href] {
pointer-events: none; pointer-events: none;
} }
@ -43,7 +53,7 @@
img { img {
display: inline-block; display: inline-block;
margin: 0 auto; //margin: 0 auto;
max-width: 100%; max-width: 100%;
&[data-placeholder="true"] { &[data-placeholder="true"] {
@ -119,26 +129,49 @@
display: inline; display: inline;
padding-top: 0.2rem; padding-top: 0.2rem;
} }
.onebox-wrapper {
white-space: normal;
a[href] {
pointer-events: all;
}
}
.code-block {
position: relative;
}
.code-language-select {
position: absolute;
right: 0.25rem;
top: -0.6rem;
border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius);
background-color: var(--primary-very-low);
color: var(--primary-medium);
font-size: var(--font-down-1-rem);
}
.html-block {
position: relative;
border: 1px dashed var(--primary-low-mid);
&::after {
position: absolute;
right: 0;
top: 0;
content: "HTML";
font-size: var(--font-down-2-rem);
color: var(--primary-low-mid);
z-index: 1;
}
}
} }
.d-editor__code-block { /*********************************************************
position: relative; Section below from prosemirror-view/style/prosemirror.css
} ********************************************************/
.d-editor__code-lang-select {
position: absolute;
right: 0.25rem;
top: -0.6rem;
border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius);
background-color: var(--primary-very-low);
color: var(--primary-medium);
font-size: var(--font-down-1-rem);
}
/*
Everything below was copied from prosemirror-view/style/prosemirror.css
*/
.ProseMirror { .ProseMirror {
position: relative; position: relative;
@ -199,8 +232,38 @@ li.ProseMirror-selectednode:after {
/* Protect against generic img rules */ /* Protect against generic img rules */
img.ProseMirror-separator { .ProseMirror-separator {
display: inline !important; display: inline !important;
border: none !important; border: none !important;
margin: 0 !important; margin: 0 !important;
} }
/************************************************************
Section below from prosemirror-gapcursor/style/gapcursor.css
***********************************************************/
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--primary);
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}

View File

@ -1,4 +1,5 @@
export default { /** @type {RichEditorExtension} */
const extension = {
// TODO(renato): make the checkbox clickable // TODO(renato): make the checkbox clickable
// TODO(renato): auto-continue checkbox list on ENTER // TODO(renato): auto-continue checkbox list on ENTER
// TODO(renato): apply .has-checkbox style to the <li> to avoid :has // TODO(renato): apply .has-checkbox style to the <li> to avoid :has
@ -65,3 +66,5 @@ const CHECKED_REGEX = /\bchecked\b/;
function hasCheckedClass(className) { function hasCheckedClass(className) {
return CHECKED_REGEX.test(className); return CHECKED_REGEX.test(className);
} }
export default extension;

View File

@ -68,7 +68,7 @@ ul li.has-checkbox {
} }
} }
.d-editor__editable { .ProseMirror {
// TODO(renato): this is temporary, we should use `has-checkbox` instead // TODO(renato): this is temporary, we should use `has-checkbox` instead
li:has(p:first-of-type > span.chcklst-box) { li:has(p:first-of-type > span.chcklst-box) {
list-style-type: none; list-style-type: none;

View File

@ -1,10 +1,15 @@
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
details: { details: {
allowGapCursor: true,
attrs: { open: { default: true } }, attrs: { open: { default: true } },
content: "summary block+", content: "summary block+",
group: "block", group: "block",
draggable: true,
selectable: true,
defining: true, defining: true,
isolating: true,
parseDOM: [{ tag: "details" }], parseDOM: [{ tag: "details" }],
toDOM: (node) => ["details", { open: node.attrs.open || undefined }, 0], toDOM: (node) => ["details", { open: node.attrs.open || undefined }, 0],
}, },
@ -36,6 +41,9 @@ export default {
}, },
summary(state, node, parent) { summary(state, node, parent) {
state.write('[details="'); state.write('[details="');
if (node.content.childCount === 0) {
state.text(" ");
}
node.content.forEach( node.content.forEach(
(child) => (child) =>
child.text && child.text &&
@ -60,3 +68,5 @@ export default {
}, },
}, },
}; };
export default extension;

View File

@ -4,7 +4,7 @@ details {
.topic-body .cooked &, .topic-body .cooked &,
.d-editor-preview, .d-editor-preview,
&.details__boxed, &.details__boxed,
.d-editor__editable & { .ProseMirror & {
background-color: var(--primary-very-low); background-color: var(--primary-very-low);
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;

View File

@ -1,4 +1,5 @@
export default { /** @type {RichEditorExtension} */
const extension = {
// TODO(renato): the rendered date needs to be localized to better match the cooked content // TODO(renato): the rendered date needs to be localized to better match the cooked content
nodeSpec: { nodeSpec: {
local_date: { local_date: {
@ -110,7 +111,12 @@ export default {
}, },
}, },
serializeNode: { serializeNode: {
local_date: (state, node) => { local_date(state, node, parent, index) {
// TODO isBoundary
// if (!isBoundary(state.out, state.out.length - 1)) {
// state.write(" ");
// }
const optionalTime = node.attrs.time ? ` time=${node.attrs.time}` : ""; const optionalTime = node.attrs.time ? ` time=${node.attrs.time}` : "";
const optionalTimezone = node.attrs.timezone const optionalTimezone = node.attrs.timezone
? ` timezone="${node.attrs.timezone}"` ? ` timezone="${node.attrs.timezone}"`
@ -119,14 +125,30 @@ export default {
state.write( state.write(
`[date=${node.attrs.date}${optionalTime}${optionalTimezone}]` `[date=${node.attrs.date}${optionalTime}${optionalTimezone}]`
); );
// TODO isBoundary
// const nextSibling =
// parent.childCount > index + 1 ? parent.child(index + 1) : null;
// if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
// state.write(" ");
// }
}, },
local_date_range: (state, node) => { local_date_range(state, node, parent, index) {
const optionalTimezone = node.attrs.timezone const optionalTimezone = node.attrs.timezone
? ` timezone="${node.attrs.timezone}"` ? ` timezone="${node.attrs.timezone}"`
: ""; : "";
state.write( state.write(
`[date-range from=${node.attrs.from} to=${node.attrs.to}${optionalTimezone}]` `[date-range from=${node.attrs.from} to=${node.attrs.to}${optionalTimezone}]`
); );
// TODO isBoundary
// const nextSibling =
// parent.childCount > index + 1 ? parent.child(index + 1) : null;
// if (nextSibling?.isText && !isBoundary(nextSibling.text, 0)) {
// state.write(" ");
// }
}, },
}, },
}; };
export default extension;

View File

@ -1 +1,45 @@
export default {}; /** @type {RichEditorExtension} */
const extension = {
nodeSpec: {
footnote: {
attrs: { id: {} },
group: "group",
content: "group*",
atom: true,
draggable: true,
selectable: false,
parseDOM: [
{
tag: "span.footnote",
preserveWhitespace: "full",
getAttrs: (dom) => {
return { id: dom.getAttribute("data-id") };
},
},
],
toDOM: (node) => {
return ["span", { class: "footnote", "data-id": node.attrs.id }, [0]];
},
},
},
parse: {
footnote_block: { ignore: true },
footnote: {
ignore: true,
// block: "footnote",
// getAttrs: (token, tokens, i) => ({ id: token.meta.id }),
},
footnote_anchor: { ignore: true, noCloseToken: true },
footnote_ref: {
node: "footnote",
getAttrs: (token, tokens, i) => ({ id: token.meta.id }),
},
},
serializeNode: {
footnote: (state, node) => {
state.write(`^[${node.attrs.id}] `);
},
},
};
export default extension;

View File

@ -1,20 +1,24 @@
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
poll: { poll: {
attrs: { attrs: {
type: {}, type: { default: null },
results: {}, results: { default: null },
public: {}, public: { default: null },
name: {}, name: {},
chartType: {}, chartType: { default: null },
close: { default: null }, close: { default: null },
groups: { default: null }, groups: { default: null },
max: { default: null }, max: { default: null },
min: { default: null }, min: { default: null },
}, },
content: "poll_container poll_info", content: "heading? bullet_list poll_info?",
group: "block", group: "block",
draggable: true, draggable: true,
selectable: true,
isolating: true,
defining: true,
parseDOM: [ parseDOM: [
{ {
tag: "div.poll", tag: "div.poll",
@ -48,17 +52,10 @@ export default {
0, 0,
], ],
}, },
poll_container: {
content: "heading? bullet_list",
group: "block",
parseDOM: [{ tag: "div.poll-container" }],
toDOM: () => ["div", { class: "poll-container" }, 0],
},
poll_info: { poll_info: {
content: "inline*", content: "inline*",
group: "block",
atom: true,
selectable: false, selectable: false,
isolating: true,
parseDOM: [{ tag: "div.poll-info" }], parseDOM: [{ tag: "div.poll-info" }],
toDOM: () => ["div", { class: "poll-info", contentEditable: false }, 0], toDOM: () => ["div", { class: "poll-info", contentEditable: false }, 0],
}, },
@ -78,13 +75,16 @@ export default {
min: token.attrGet("data-poll-min"), min: token.attrGet("data-poll-min"),
}), }),
}, },
poll_container: { block: "poll_container" }, poll_container: { ignore: true },
poll_title: { block: "heading" }, poll_title: { block: "heading" },
poll_info: { block: "poll_info" }, poll_info: { block: "poll_info" },
poll_info_counts: { ignore: true }, poll_info_counts: { ignore: true },
poll_info_counts_count: { ignore: true }, poll_info_counts_count: { ignore: true },
poll_info_number: { ignore: true }, poll_info_number: { ignore: true },
poll_info_label: { ignore: true }, poll_info_label_open(state) {
state.addText(" ");
},
poll_info_label_close() {},
}, },
serializeNode: { serializeNode: {
poll(state, node) { poll(state, node) {
@ -96,9 +96,8 @@ export default {
state.renderContent(node); state.renderContent(node);
state.write("[/poll]\n\n"); state.write("[/poll]\n\n");
}, },
poll_container(state, node) {
state.renderContent(node);
},
poll_info() {}, poll_info() {},
}, },
}; };
export default extension;

View File

@ -494,8 +494,9 @@ div.poll-outer {
} }
} }
.d-editor__editable { .ProseMirror {
.poll { .poll {
margin-bottom: 1rem;
ul { ul {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
@ -569,7 +570,7 @@ body.crawler {
} }
} }
.d-editor__editable .poll { .ProseMirror .poll {
border: 1px solid var(--primary-low); border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius); border-radius: var(--d-border-radius);
padding: 1rem; padding: 1rem;

View File

@ -1,18 +1,14 @@
const INLINE_NODES = ["inline_spoiler", "spoiler"]; const SPOILER_NODES = ["inline_spoiler", "spoiler"];
export default { /** @type {RichEditorExtension} */
const extension = {
nodeSpec: { nodeSpec: {
spoiler: { spoiler: {
attrs: { blurred: { default: true } }, attrs: { blurred: { default: true } },
group: "block", group: "block",
content: "block+", content: "block+",
defining: true,
parseDOM: [{ tag: "div.spoiled" }], parseDOM: [{ tag: "div.spoiled" }],
toDOM: (node) => [ toDOM: () => ["div", { class: "spoiled" }, 0],
"div",
{ class: `spoiled ${node.attrs.blurred ? "spoiler-blurred" : ""}` },
0,
],
}, },
inline_spoiler: { inline_spoiler: {
attrs: { blurred: { default: true } }, attrs: { blurred: { default: true } },
@ -20,11 +16,7 @@ export default {
inline: true, inline: true,
content: "inline*", content: "inline*",
parseDOM: [{ tag: "span.spoiled" }], parseDOM: [{ tag: "span.spoiled" }],
toDOM: (node) => [ toDOM: () => ["span", { class: "spoiled" }, 0],
"span",
{ class: `spoiled ${node.attrs.blurred ? "spoiler-blurred" : ""}` },
0,
],
}, },
}, },
parse: { parse: {
@ -32,8 +24,10 @@ export default {
wrap_bbcode(state, token) { wrap_bbcode(state, token) {
if (token.nesting === 1 && token.attrGet("class") === "spoiler") { if (token.nesting === 1 && token.attrGet("class") === "spoiler") {
state.openNode(state.schema.nodes.spoiler); state.openNode(state.schema.nodes.spoiler);
} else if (token.nesting === -1) { return true;
} else if (token.nesting === -1 && state.top().type.name === "spoiler") {
state.closeNode(); state.closeNode();
return true;
} }
}, },
}, },
@ -49,18 +43,42 @@ export default {
state.write("[/spoiler]"); state.write("[/spoiler]");
}, },
}, },
plugins: { plugins({ pmState: { Plugin }, pmView: { Decoration, DecorationSet } }) {
props: { return new Plugin({
handleClickOn(view, pos, node, nodePos, event, direct) { props: {
if (INLINE_NODES.includes(node.type.name)) { decorations(state) {
view.dispatch( return this.getState(state);
view.state.tr.setNodeMarkup(nodePos, null, { },
blurred: !node.attrs.blurred, handleClickOn(view, pos, node, nodePos) {
}) if (SPOILER_NODES.includes(node.type.name)) {
); const decoSet = this.getState(view.state) || DecorationSet.empty;
return true;
} const isBlurred =
decoSet.find(nodePos, nodePos + node.nodeSize).length > 0;
const newDeco = isBlurred
? decoSet.remove(decoSet.find(nodePos, nodePos + node.nodeSize))
: decoSet.add(view.state.doc, [
Decoration.node(nodePos, nodePos + node.nodeSize, {
class: "spoiler-blurred",
}),
]);
view.dispatch(view.state.tr.setMeta(this, newDeco));
return true;
}
},
}, },
}, state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
return tr.getMeta(this) || set.map(tr.mapping, tr.doc);
},
},
});
}, },
}; };
export default extension;

View File

@ -53,7 +53,7 @@
} }
} }
.d-editor__editable { .ProseMirror {
.spoiled:not(.spoiler-blurred) { .spoiled:not(.spoiler-blurred) {
box-shadow: 0 0 4px 4px rgba(var(--primary-rgb), 0.2); box-shadow: 0 0 4px 4px rgba(var(--primary-rgb), 0.2);
inline-size: max-content; inline-size: max-content;

View File

@ -323,6 +323,45 @@ importers:
pretty-text: pretty-text:
specifier: workspace:1.0.0 specifier: workspace:1.0.0
version: link:../pretty-text version: link:../pretty-text
prosemirror-commands:
specifier: ^1.6.0
version: 1.6.2
prosemirror-dropcursor:
specifier: ^1.8.1
version: 1.8.1
prosemirror-gapcursor:
specifier: ^1.3.2
version: 1.3.2
prosemirror-highlightjs:
specifier: ^0.9.1
version: 0.9.1(highlight.js@11.11.1)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.1)
prosemirror-history:
specifier: ^1.4.1
version: 1.4.1
prosemirror-inputrules:
specifier: ^1.4.0
version: 1.4.0
prosemirror-keymap:
specifier: ^1.2.2
version: 1.2.2
prosemirror-markdown:
specifier: ^1.13.1
version: 1.13.1
prosemirror-model:
specifier: ^1.23.0
version: 1.24.1
prosemirror-schema-list:
specifier: ^1.4.1
version: 1.5.0
prosemirror-state:
specifier: ^1.4.3
version: 1.4.3
prosemirror-transform:
specifier: ^1.10.2
version: 1.10.2
prosemirror-view:
specifier: ^1.34.3
version: 1.37.1
devDependencies: devDependencies:
'@babel/core': '@babel/core':
specifier: ^7.26.0 specifier: ^7.26.0
@ -6612,6 +6651,9 @@ packages:
ordered-read-streams@1.0.1: ordered-read-streams@1.0.1:
resolution: {integrity: sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==} resolution: {integrity: sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
os-locale@5.0.0: os-locale@5.0.0:
resolution: {integrity: sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA==} resolution: {integrity: sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -6968,6 +7010,50 @@ packages:
proper-lockfile@4.1.2: proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
prosemirror-commands@1.6.2:
resolution: {integrity: sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==}
prosemirror-dropcursor@1.8.1:
resolution: {integrity: sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==}
prosemirror-gapcursor@1.3.2:
resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==}
prosemirror-highlightjs@0.9.1:
resolution: {integrity: sha512-aqcXUCM4Dbc+0DoORrmF4MWrdIJuaJKUpZvzi1xy0HEx06J5vTlnwR25xCUIbxD3ilOtkabB36QqY3WQ03OuzQ==}
peerDependencies:
highlight.js: ^11.6.0
prosemirror-model: ^1.18.1
prosemirror-state: ^1.4.1
prosemirror-view: ^1.26.5
prosemirror-history@1.4.1:
resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==}
prosemirror-inputrules@1.4.0:
resolution: {integrity: sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==}
prosemirror-keymap@1.2.2:
resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==}
prosemirror-markdown@1.13.1:
resolution: {integrity: sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==}
prosemirror-model@1.24.1:
resolution: {integrity: sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==}
prosemirror-schema-list@1.5.0:
resolution: {integrity: sha512-gg1tAfH1sqpECdhIHOA/aLg2VH3ROKBWQ4m8Qp9mBKrOxQRW61zc+gMCI8nh22gnBzd1t2u1/NPLmO3nAa3ssg==}
prosemirror-state@1.4.3:
resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==}
prosemirror-transform@1.10.2:
resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==}
prosemirror-view@1.37.1:
resolution: {integrity: sha512-MEAnjOdXU1InxEmhjgmEzQAikaS6lF3hD64MveTPpjOGNTl87iRLA1HupC/DEV6YuK7m4Q9DHFNTjwIVtqz5NA==}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -7217,6 +7303,9 @@ packages:
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
route-recognizer@0.3.4: route-recognizer@0.3.4:
resolution: {integrity: sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==} resolution: {integrity: sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==}
@ -8103,6 +8192,9 @@ packages:
vscode-uri@3.0.8: vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -15440,6 +15532,8 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
orderedmap@2.1.1: {}
os-locale@5.0.0: os-locale@5.0.0:
dependencies: dependencies:
execa: 4.1.0 execa: 4.1.0
@ -15755,6 +15849,81 @@ snapshots:
retry: 0.12.0 retry: 0.12.0
signal-exit: 3.0.7 signal-exit: 3.0.7
prosemirror-commands@1.6.2:
dependencies:
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-dropcursor@1.8.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.1
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.1
prosemirror-highlightjs@0.9.1(highlight.js@11.11.1)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.1):
dependencies:
highlight.js: 11.11.1
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.1
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.1
rope-sequence: 1.3.4
prosemirror-inputrules@1.4.0:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-keymap@1.2.2:
dependencies:
prosemirror-state: 1.4.3
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.1:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.0.0
prosemirror-model: 1.24.1
prosemirror-model@1.24.1:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-list@1.5.0:
dependencies:
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.24.1
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.1
prosemirror-transform@1.10.2:
dependencies:
prosemirror-model: 1.24.1
prosemirror-view@1.37.1:
dependencies:
prosemirror-model: 1.24.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@ -16037,6 +16206,8 @@ snapshots:
dependencies: dependencies:
glob: 10.4.5 glob: 10.4.5
rope-sequence@1.3.4: {}
route-recognizer@0.3.4: {} route-recognizer@0.3.4: {}
router_js@8.0.6(route-recognizer@0.3.4)(rsvp@4.8.5): router_js@8.0.6(route-recognizer@0.3.4)(rsvp@4.8.5):
@ -17125,6 +17296,8 @@ snapshots:
vscode-uri@3.0.8: {} vscode-uri@3.0.8: {}
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0