DEV: prosemirror
This commit is contained in:
parent
030aec0342
commit
954ad7218d
|
@ -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}}
|
||||||
|
|
|
@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
*/
|
|
@ -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);
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default async function loadRichEditor() {
|
||||||
|
return (
|
||||||
|
await import("discourse/static/prosemirror/components/prosemirror-editor")
|
||||||
|
).default;
|
||||||
|
}
|
|
@ -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 = {};
|
||||||
|
|
|
@ -52,6 +52,7 @@ export default class UppyComposerUpload {
|
||||||
uploadPreProcessors;
|
uploadPreProcessors;
|
||||||
uploadHandlers;
|
uploadHandlers;
|
||||||
|
|
||||||
|
/** @type {TextManipulation} */
|
||||||
textManipulation;
|
textManipulation;
|
||||||
|
|
||||||
#inProgressUploads = [];
|
#inProgressUploads = [];
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 },
|
|
||||||
};
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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))
|
||||||
|
);
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
173
pnpm-lock.yaml
173
pnpm-lock.yaml
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue