DEV: prosemirror
This commit is contained in:
parent
3fe98a0387
commit
8b50393229
|
@ -2,43 +2,33 @@ 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>
|
const ComposerToggleSwitch = <template>
|
||||||
<div
|
<div class="composer-toggle-switch">
|
||||||
class="{{concatClass
|
<label class="composer-toggle-switch__label">
|
||||||
'composer-toggle-switch'
|
{{! template-lint-disable no-redundant-role }}
|
||||||
(if @state '--rte' '--markdown')
|
<button
|
||||||
}}"
|
class="composer-toggle-switch__checkbox"
|
||||||
>
|
type="button"
|
||||||
|
role="switch"
|
||||||
{{! template-lint-disable no-redundant-role }}
|
aria-checked={{if @state "true" "false"}}
|
||||||
<button
|
...attributes
|
||||||
class="composer-toggle-switch__button"
|
></button>
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-pressed={{if @state "true" "false"}}
|
|
||||||
...attributes
|
|
||||||
>
|
|
||||||
{{! template-lint-enable no-redundant-role }}
|
{{! template-lint-enable no-redundant-role }}
|
||||||
|
|
||||||
<span class="composer-toggle-switch__slider" focusable="false">
|
<span class="composer-toggle-switch__checkbox-slider">
|
||||||
<span
|
<span
|
||||||
class={{concatClass
|
class={{concatClass
|
||||||
"composer-toggle-switch__left-icon"
|
"composer-toggle-switch__left-icon"
|
||||||
(unless @state "--active")
|
(unless @state "--active")
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
>{{icon "fab-markdown"}}</span>
|
>{{icon "fab-markdown"}}</span>
|
||||||
<span
|
<span
|
||||||
class={{concatClass
|
class={{concatClass
|
||||||
"composer-toggle-switch__right-icon"
|
"composer-toggle-switch__right-icon"
|
||||||
(if @state "--active")
|
(if @state "--active")
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
>{{icon "a"}}</span>
|
>{{icon "a"}}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</label>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>;
|
</template>;
|
||||||
|
|
||||||
|
|
|
@ -9,13 +9,13 @@
|
||||||
>
|
>
|
||||||
<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
|
||||||
@state={{this.isRichEditorEnabled}}
|
@state={{this.isRichEditorEnabled}}
|
||||||
{{on "click" this.toggleRichEditor}}
|
{{on "click" this.toggleRichEditor}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#each this.toolbar.groups as |group|}}
|
{{#each this.toolbar.groups as |group|}}
|
||||||
{{#each group.buttons as |b|}}
|
{{#each group.buttons as |b|}}
|
||||||
{{#if (b.condition this)}}
|
{{#if (b.condition this)}}
|
||||||
{{#if b.popupMenu}}
|
{{#if b.popupMenu}}
|
||||||
|
@ -56,6 +56,8 @@
|
||||||
@change={{this.onChange}}
|
@change={{this.onChange}}
|
||||||
@focusIn={{this.handleFocusIn}}
|
@focusIn={{this.handleFocusIn}}
|
||||||
@focusOut={{this.handleFocusOut}}
|
@focusOut={{this.handleFocusOut}}
|
||||||
|
@categoryId={{@categoryId}}
|
||||||
|
@topicId={{@topicId}}
|
||||||
@id={{this.textAreaId}}
|
@id={{this.textAreaId}}
|
||||||
/>
|
/>
|
||||||
<PopupInputTip @validation={{this.validation}} />
|
<PopupInputTip @validation={{this.validation}} />
|
||||||
|
|
|
@ -11,12 +11,14 @@ import {
|
||||||
getNodeViews,
|
getNodeViews,
|
||||||
getPlugins,
|
getPlugins,
|
||||||
} from "discourse/lib/composer/rich-editor-extensions";
|
} from "discourse/lib/composer/rich-editor-extensions";
|
||||||
|
import * as ProsemirrorModel from "prosemirror-model";
|
||||||
|
import * as ProsemirrorView from "prosemirror-view";
|
||||||
import { createHighlight } from "../plugins/code-highlight";
|
import { createHighlight } from "../plugins/code-highlight";
|
||||||
import { baseKeymap } from "prosemirror-commands";
|
import { baseKeymap } from "prosemirror-commands";
|
||||||
import { dropCursor } from "prosemirror-dropcursor";
|
import { dropCursor } from "prosemirror-dropcursor";
|
||||||
import { gapCursor } from "prosemirror-gapcursor";
|
|
||||||
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";
|
||||||
|
@ -54,18 +56,20 @@ export default class ProsemirrorEditor extends Component {
|
||||||
|
|
||||||
this.plugins ??= [
|
this.plugins ??= [
|
||||||
buildInputRules(this.schema),
|
buildInputRules(this.schema),
|
||||||
// TODO buildPasteRules(),
|
|
||||||
keymap(buildKeymap(this.schema, keymapFromArgs)),
|
keymap(buildKeymap(this.schema, keymapFromArgs)),
|
||||||
keymap(baseKeymap),
|
keymap(baseKeymap),
|
||||||
dropCursor({ color: "var(--primary)" }),
|
dropCursor({ color: "var(--primary)" }),
|
||||||
gapCursor(),
|
|
||||||
history(),
|
history(),
|
||||||
placeholder(this.args.placeholder),
|
placeholder(this.args.placeholder),
|
||||||
createHighlight(),
|
createHighlight(),
|
||||||
...getPlugins().map((plugin) =>
|
...getPlugins().map((plugin) =>
|
||||||
// can be either a function that receives the Plugin class,
|
typeof plugin === "function"
|
||||||
// or a plugin spec to be passed directly to the Plugin constructor
|
? plugin({
|
||||||
typeof plugin === "function" ? plugin(Plugin) : new Plugin(plugin)
|
...ProsemirrorState,
|
||||||
|
...ProsemirrorModel,
|
||||||
|
...ProsemirrorView,
|
||||||
|
})
|
||||||
|
: new Plugin(plugin)
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -75,6 +79,10 @@ export default class ProsemirrorEditor extends Component {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.view = new EditorView(this.rootElement, {
|
this.view = new EditorView(this.rootElement, {
|
||||||
|
discourse: {
|
||||||
|
topicId: this.args.topicId,
|
||||||
|
categoryId: this.args.categoryId,
|
||||||
|
},
|
||||||
nodeViews: this.args.nodeViews ?? getNodeViews(),
|
nodeViews: this.args.nodeViews ?? getNodeViews(),
|
||||||
state: this.state,
|
state: this.state,
|
||||||
attributes: { class: "d-editor-input d-editor__editable" },
|
attributes: { class: "d-editor-input d-editor__editable" },
|
||||||
|
@ -98,8 +106,7 @@ export default class ProsemirrorEditor extends Component {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handleKeyDown: (view, event) => {
|
handleKeyDown: (view, event) => {
|
||||||
// this happens before the autocomplete event, so we check if it's open
|
// skip the event if it's an Enter keypress and the autocomplete is open
|
||||||
// TODO(renato): find a better way to handle these events, or just a better check
|
|
||||||
return (
|
return (
|
||||||
event.key === "Enter" && !!document.querySelector(".autocomplete")
|
event.key === "Enter" && !!document.querySelector(".autocomplete")
|
||||||
);
|
);
|
||||||
|
@ -114,14 +121,13 @@ export default class ProsemirrorEditor extends Component {
|
||||||
|
|
||||||
this.destructor = this.args.onSetup(this.textManipulation);
|
this.destructor = this.args.onSetup(this.textManipulation);
|
||||||
|
|
||||||
await this.convertFromValue();
|
this.convertFromValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
async convertFromValue() {
|
convertFromValue() {
|
||||||
const doc = await convertFromMarkdown(this.schema, this.args.value);
|
const doc = convertFromMarkdown(this.schema, this.args.value);
|
||||||
|
|
||||||
// doc.check();
|
|
||||||
// console.log("Resulting doc:", doc);
|
// console.log("Resulting doc:", doc);
|
||||||
|
|
||||||
const tr = this.state.tr
|
const tr = this.state.tr
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { common, createLowlight } from "lowlight";
|
||||||
|
|
||||||
|
class CodeBlockWithLangSelectorNodeView {
|
||||||
|
changeListener = (e) =>
|
||||||
|
this.view.dispatch(
|
||||||
|
this.view.state.tr.setNodeMarkup(this.getPos(), null, {
|
||||||
|
params: e.target.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(node, view, getPos) {
|
||||||
|
this.node = node;
|
||||||
|
this.view = view;
|
||||||
|
this.getPos = getPos;
|
||||||
|
|
||||||
|
const code = document.createElement("code");
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.appendChild(code);
|
||||||
|
pre.classList.add("d-editor__code-block");
|
||||||
|
pre.appendChild(this.buildSelect());
|
||||||
|
|
||||||
|
this.dom = pre;
|
||||||
|
this.contentDOM = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSelect() {
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.contentEditable = false;
|
||||||
|
select.addEventListener("change", this.changeListener);
|
||||||
|
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 === this.node.attrs.params;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(node) {
|
||||||
|
return node.type === this.node.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.dom.removeEventListener("change", this.changeListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
nodeViews: { code_block: CodeBlockWithLangSelectorNodeView },
|
||||||
|
plugins: {
|
||||||
|
props: {
|
||||||
|
// 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 paragraph = view.state.schema.nodes.paragraph.create(
|
||||||
|
null,
|
||||||
|
codeBlock.content
|
||||||
|
);
|
||||||
|
tr.replaceWith(
|
||||||
|
view.state.selection.$from.before(),
|
||||||
|
view.state.selection.$from.after(),
|
||||||
|
paragraph
|
||||||
|
);
|
||||||
|
tr.setSelection(
|
||||||
|
new view.state.selection.constructor(tr.doc.resolve(1))
|
||||||
|
);
|
||||||
|
|
||||||
|
view.dispatch(tr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -30,7 +30,7 @@ export default {
|
||||||
|
|
||||||
inputRules: [
|
inputRules: [
|
||||||
{
|
{
|
||||||
match: /#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101}) $/,
|
match: /(?<=^|\W)#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101}) $/,
|
||||||
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, [
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
nodeSpec: {
|
||||||
|
// TODO(renato): html_block should be like a passthrough code block
|
||||||
|
html_block: { block: "paragraph", noCloseToken: true },
|
||||||
|
},
|
||||||
|
};
|
|
@ -19,6 +19,8 @@ const ALLOWED_INLINE = [
|
||||||
"mark",
|
"mark",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ALL_ALLOWED_TAGS = [...Object.keys(HTML_INLINE_MARKS), ...ALLOWED_INLINE];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
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
|
||||||
|
@ -47,7 +49,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ALLOWED_INLINE.includes(tagName)) {
|
if (ALLOWED_INLINE.includes(tagName)) {
|
||||||
state.openNode(state.schema.nodeType("html_inline"), {
|
state.openNode(state.schema.nodeType.html_inline, {
|
||||||
tag: tagName,
|
tag: tagName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -77,16 +79,37 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
inputRules: {
|
inputRules: {
|
||||||
match: new RegExp(`<(${ALLOWED_INLINE.join("|")})>`),
|
match: new RegExp(`<(${ALL_ALLOWED_TAGS.join("|")})>$`, "i"),
|
||||||
handler: (state, match, start, end) => {
|
handler: (state, match, start, end) => {
|
||||||
const tag = match[1];
|
const tag = match[1];
|
||||||
|
|
||||||
// TODO not finished
|
const markName = HTML_INLINE_MARKS[tag];
|
||||||
state.tr.replaceWith(
|
|
||||||
start,
|
const tr = state.tr;
|
||||||
end,
|
|
||||||
state.schema.nodes.html_inline.create({ tag })
|
if (markName) {
|
||||||
|
tr.delete(start, end);
|
||||||
|
tr.insertText(" ");
|
||||||
|
tr.addMark(start, start + 1, state.schema.marks[markName].create());
|
||||||
|
tr.removeStoredMark(state.schema.marks[markName]);
|
||||||
|
} else {
|
||||||
|
tr.replaceWith(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
state.schema.nodes.html_inline.create({ tag }, [
|
||||||
|
state.schema.text(" "),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.insertText(" ");
|
||||||
|
tr.setSelection(
|
||||||
|
state.selection.constructor.create(tr.doc, start, start + 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return tr;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -106,7 +106,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: (Plugin) => {
|
plugins: ({ Plugin }) => {
|
||||||
const shortUrlResolver = new Plugin({
|
const shortUrlResolver = new Plugin({
|
||||||
state: {
|
state: {
|
||||||
init() {
|
init() {
|
||||||
|
|
|
@ -1,35 +1,43 @@
|
||||||
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
|
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
|
||||||
import codeLangSelector from "./code-lang-selector";
|
import codeBlock from "./code-block";
|
||||||
import emojiExtension from "./emoji";
|
import emoji from "./emoji";
|
||||||
import hashtagExtension from "./hashtag";
|
import hashtag from "./hashtag";
|
||||||
import headingExtension from "./heading";
|
import heading from "./heading";
|
||||||
import htmlInlineExtension from "./html-inline";
|
import htmlBlock from "./html-block";
|
||||||
import imageExtension from "./image";
|
import htmlInline from "./html-inline";
|
||||||
import linkExtension from "./link";
|
import image from "./image";
|
||||||
import mentionExtension from "./mention";
|
import link from "./link";
|
||||||
import quoteExtension from "./quote";
|
import markdownPaste from "./markdown-paste";
|
||||||
import strikethroughExtension from "./strikethrough";
|
import mention from "./mention";
|
||||||
import tableExtension from "./table";
|
import onebox from "./onebox";
|
||||||
|
import quote from "./quote";
|
||||||
|
import strikethrough from "./strikethrough";
|
||||||
|
import table from "./table";
|
||||||
|
import trailingParagraph from "./trailing-paragraph";
|
||||||
import typographerReplacements from "./typographer-replacements";
|
import typographerReplacements from "./typographer-replacements";
|
||||||
import underlineExtension from "./underline";
|
import underline from "./underline";
|
||||||
|
|
||||||
const defaultExtensions = [
|
const defaultExtensions = [
|
||||||
emojiExtension,
|
emoji,
|
||||||
// image must be after emoji
|
// image must be after emoji
|
||||||
imageExtension,
|
image,
|
||||||
hashtagExtension,
|
hashtag,
|
||||||
mentionExtension,
|
mention,
|
||||||
strikethroughExtension,
|
strikethrough,
|
||||||
underlineExtension,
|
underline,
|
||||||
htmlInlineExtension,
|
htmlInline,
|
||||||
linkExtension,
|
htmlBlock,
|
||||||
headingExtension,
|
link,
|
||||||
|
heading,
|
||||||
|
codeBlock,
|
||||||
|
quote,
|
||||||
|
onebox,
|
||||||
|
trailingParagraph,
|
||||||
typographerReplacements,
|
typographerReplacements,
|
||||||
codeLangSelector,
|
markdownPaste,
|
||||||
quoteExtension,
|
|
||||||
|
|
||||||
// table must be last
|
// table must be last
|
||||||
tableExtension,
|
table,
|
||||||
];
|
];
|
||||||
|
|
||||||
defaultExtensions.forEach(registerRichEditorExtension);
|
defaultExtensions.forEach(registerRichEditorExtension);
|
||||||
|
|
|
@ -1,32 +1,39 @@
|
||||||
const HTTP_MAILTO_REGEX = new RegExp(
|
|
||||||
/(?:(?:(https|http|ftp)+):\/\/)?(?:\S+(?::\S*)?(@))?(?:(?:([a-z0-9][a-z0-9\-]*)?[a-z0-9]+)(?:\.(?:[a-z0-9\-])*[a-z0-9]+)*(?:\.(?:[a-z]{2,})(:\d{1,5})?))(?:\/[^\s]*)?\s $/
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO use site settings
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
inputRules: [
|
inputRules: [
|
||||||
{
|
// []() replacement
|
||||||
match: HTTP_MAILTO_REGEX,
|
({ schema, markInputRule }) =>
|
||||||
handler: (state, match, start, end) => {
|
markInputRule(
|
||||||
const markType = state.schema.marks.link;
|
/\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/,
|
||||||
|
schema.marks.link,
|
||||||
const resolvedStart = state.doc.resolve(start);
|
(match) => {
|
||||||
if (!resolvedStart.parent.type.allowsMarkType(markType)) {
|
return { href: match[2], title: match[3] };
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
),
|
||||||
const link = match[0].substring(0, match[0].length - 1);
|
// TODO(renato): auto-linkify when typing (https://github.com/markdown-it/markdown-it/blob/master/lib/rules_inline/autolink.mjs)
|
||||||
const linkAttrs =
|
|
||||||
match[2] === "@"
|
|
||||||
? { href: "mailto:" + link }
|
|
||||||
: { href: link, target: "_blank" };
|
|
||||||
const linkTo = markType.create(linkAttrs);
|
|
||||||
return state.tr
|
|
||||||
.removeMark(start, end, markType)
|
|
||||||
.addMark(start, end, linkTo)
|
|
||||||
.insertText(match[5], start);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
plugins: ({ Plugin, Slice, Fragment }) =>
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
// 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) {
|
||||||
|
// TODO(renato): a less naive regex, reuse existing
|
||||||
|
if (!text.match(/^https?:\/\//) || view.state.selection.empty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marks = $context.marks();
|
||||||
|
const selectedText = view.state.doc.textBetween(
|
||||||
|
view.state.selection.from,
|
||||||
|
view.state.selection.to
|
||||||
|
);
|
||||||
|
const textNode = view.state.schema.text(selectedText, [
|
||||||
|
...marks,
|
||||||
|
view.state.schema.marks.link.create({ href: text }),
|
||||||
|
]);
|
||||||
|
return new Slice(Fragment.from(textNode), 0, 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { convertFromMarkdown } from "../lib/parser";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins({ Plugin, Fragment, Slice }) {
|
||||||
|
return new Plugin({
|
||||||
|
props: {
|
||||||
|
clipboardTextParser(text, $context, plain, view) {
|
||||||
|
const { content } = convertFromMarkdown(view.state.schema, text);
|
||||||
|
|
||||||
|
return Slice.maxOpen(Fragment.from(content));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -34,8 +34,8 @@ export default {
|
||||||
|
|
||||||
inputRules: [
|
inputRules: [
|
||||||
{
|
{
|
||||||
// TODO: pass unicodeUsernames?
|
// TODO(renato): pass unicodeUsernames?
|
||||||
match: new RegExp(`(${mentionRegex().source}) $`),
|
match: new RegExp(`(?<=^|\\W)(${mentionRegex().source}) $`),
|
||||||
handler: (state, match, start, end) =>
|
handler: (state, match, start, end) =>
|
||||||
state.selection.$from.nodeBefore?.type !== state.schema.nodes.mention &&
|
state.selection.$from.nodeBefore?.type !== state.schema.nodes.mention &&
|
||||||
state.tr.replaceWith(start, end, [
|
state.tr.replaceWith(start, end, [
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { cachedInlineOnebox } from "pretty-text/inline-oneboxer";
|
||||||
|
import { lookupCache } from "pretty-text/oneboxer-cache";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
nodeSpec: {
|
||||||
|
onebox: {
|
||||||
|
attrs: { url: {}, html: {} },
|
||||||
|
selectable: false,
|
||||||
|
group: "inline",
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: "aside.onebox",
|
||||||
|
getAttrs(dom) {
|
||||||
|
return { url: dom["data-onebox-src"], html: dom.outerHTML };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM(node) {
|
||||||
|
// const dom = document.createElement("aside");
|
||||||
|
// dom.outerHTML = node.attrs.html;
|
||||||
|
|
||||||
|
// TODO(renato): revisit?
|
||||||
|
return new DOMParser().parseFromString(node.attrs.html, "text/html")
|
||||||
|
.body.firstChild;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serializeNode: {
|
||||||
|
onebox(state, node) {
|
||||||
|
state.write(node.attrs.url);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: ({ Plugin }) => {
|
||||||
|
const plugin = new Plugin({
|
||||||
|
state: {
|
||||||
|
init() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
apply(tr, value) {
|
||||||
|
// TODO(renato)
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
view() {
|
||||||
|
return {
|
||||||
|
update(view, prevState) {
|
||||||
|
if (prevState.doc.eq(view.state.doc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("discourse", view.props.discourse);
|
||||||
|
|
||||||
|
const unresolvedLinks = plugin.getState(view.state);
|
||||||
|
|
||||||
|
// console.log(unresolvedLinks);
|
||||||
|
|
||||||
|
for (const unresolved of unresolvedLinks) {
|
||||||
|
const isInline = unresolved.isInline;
|
||||||
|
// console.log(isInline, cachedInlineOnebox(unresolved.text));
|
||||||
|
|
||||||
|
const className = isInline
|
||||||
|
? "onebox-loading"
|
||||||
|
: "inline-onebox-loading";
|
||||||
|
|
||||||
|
if (!isInline) {
|
||||||
|
// console.log(lookupCache(unresolved.text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return plugin;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidUrl(text) {
|
||||||
|
try {
|
||||||
|
new URL(text); // If it can be parsed as a URL, it's valid.
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeInline(state, pos) {
|
||||||
|
const resolvedPos = state.doc.resolve(pos);
|
||||||
|
const parent = resolvedPos.parent;
|
||||||
|
|
||||||
|
return parent.childCount !== 1;
|
||||||
|
}
|
|
@ -83,7 +83,7 @@ export default {
|
||||||
state.renderContent(n);
|
state.renderContent(n);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
state.write("[/quote]\n");
|
state.write("[/quote]\n\n");
|
||||||
},
|
},
|
||||||
quote_title() {},
|
quote_title() {},
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,6 +15,8 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
inputRules: ({ schema, markInputRule }) =>
|
||||||
|
markInputRule(/~~([^~]+)~~$/, schema.marks.strikethrough),
|
||||||
parse: {
|
parse: {
|
||||||
s: { mark: "strikethrough" },
|
s: { mark: "strikethrough" },
|
||||||
bbcode_s: { mark: "strikethrough" },
|
bbcode_s: { mark: "strikethrough" },
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
export default {
|
export default {
|
||||||
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,
|
||||||
|
@ -61,7 +61,15 @@ export default {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
toDOM(node) {
|
toDOM(node) {
|
||||||
return ["th", { style: `text-align: ${node.attrs.alignment}` }, 0];
|
return [
|
||||||
|
"th",
|
||||||
|
{
|
||||||
|
style: node.attrs.alignment
|
||||||
|
? `text-align: ${node.attrs.alignment}`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
table_cell: {
|
table_cell: {
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
export default {
|
||||||
|
plugins({ Plugin, PluginKey }) {
|
||||||
|
const plugin = new PluginKey("trailing-paragraph");
|
||||||
|
|
||||||
|
return new Plugin({
|
||||||
|
key: plugin,
|
||||||
|
appendTransaction(_, __, state) {
|
||||||
|
if (!plugin.getState(state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.tr.insert(
|
||||||
|
state.doc.content.size,
|
||||||
|
state.schema.nodes.paragraph.create()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
init(_, state) {
|
||||||
|
return state.doc.lastChild.type !== state.schema.nodes.paragraph;
|
||||||
|
},
|
||||||
|
apply(tr, value) {
|
||||||
|
if (!tr.docChanged) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr.doc.lastChild.type !== tr.doc.type.schema.nodes.paragraph;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -7,6 +7,8 @@ export default {
|
||||||
parseDOM: [{ tag: "u" }],
|
parseDOM: [{ tag: "u" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
inputRules: ({ schema, markInputRule }) =>
|
||||||
|
markInputRule(/\[u]$/, schema.marks.underline),
|
||||||
parse: {
|
parse: {
|
||||||
bbcode_u: { mark: "underline" },
|
bbcode_u: { mark: "underline" },
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,49 +1,56 @@
|
||||||
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 { parseAsync } from "discourse/lib/text";
|
import { parse as markdownItParse } from "discourse/static/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
|
||||||
// a solution may be a markStack in the state ignoring nested marks
|
// a solution may be a markStack in the state ignoring nested marks
|
||||||
|
|
||||||
const [parseFunctions, parseDefinitions] = Object.entries(getParsers()).reduce(
|
|
||||||
([funcs, nonFuncs], [key, value]) => {
|
|
||||||
if (typeof value === "function") {
|
|
||||||
funcs[key] = value;
|
|
||||||
} else {
|
|
||||||
nonFuncs[key] = value;
|
|
||||||
}
|
|
||||||
return [funcs, nonFuncs];
|
|
||||||
},
|
|
||||||
[{}, {}]
|
|
||||||
);
|
|
||||||
|
|
||||||
const parseTokens = {
|
const parseTokens = {
|
||||||
...defaultMarkdownParser.tokens,
|
...defaultMarkdownParser.tokens,
|
||||||
|
|
||||||
// Custom
|
|
||||||
bbcode_b: { mark: "strong" },
|
bbcode_b: { mark: "strong" },
|
||||||
bbcode_i: { mark: "em" },
|
bbcode_i: { mark: "em" },
|
||||||
// TODO(renato): html_block should be like a passthrough code block
|
|
||||||
html_block: { block: "paragraph", noCloseToken: true },
|
|
||||||
...parseDefinitions,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Overriding Prosemirror default parse definitions
|
// Overriding Prosemirror default parse definitions with custom handlers
|
||||||
const postParseTokens = {
|
const postParseTokens = {
|
||||||
softbreak: (state) => state.addText("\n"),
|
softbreak: (state) => state.addNode(state.schema.nodes.hard_break),
|
||||||
...parseFunctions,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function convertFromMarkdown(schema, text) {
|
let parseOptions;
|
||||||
const tokens = await parseAsync(text);
|
function initializeParser() {
|
||||||
|
if (parseOptions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(getParsers())) {
|
||||||
|
if (typeof value === "function") {
|
||||||
|
postParseTokens[key] = value;
|
||||||
|
} else {
|
||||||
|
parseTokens[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuresOverride = [...defaultFeatures, ...loadPluginFeatures()]
|
||||||
|
.map(({ id }) => id)
|
||||||
|
// Avoid oneboxing when parsing, we'll handle that separately
|
||||||
|
.filter((id) => id !== "onebox");
|
||||||
|
|
||||||
|
parseOptions = { featuresOverride };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertFromMarkdown(schema, text) {
|
||||||
|
initializeParser();
|
||||||
|
|
||||||
|
const tokens = markdownItParse(text, parseOptions);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// workaround for custom (fn) handlers
|
|
||||||
for (const [key, callback] of Object.entries(postParseTokens)) {
|
for (const [key, callback] of Object.entries(postParseTokens)) {
|
||||||
parser.tokenHandlers[key] = callback;
|
parser.tokenHandlers[key] = callback;
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,26 +93,30 @@ export default class TextManipulation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO other cases, probably through md parser
|
const text = i18n(`composer.${exampleKey}`);
|
||||||
|
const doc = convertFromMarkdown(this.schema, head + text + tail);
|
||||||
|
|
||||||
|
this.view.dispatch(
|
||||||
|
this.view.state.tr.replaceWith(sel.start, sel.end, doc.content.firstChild)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addText(sel, text, options) {
|
addText(sel, text, options) {
|
||||||
const doc = await convertFromMarkdown(
|
const doc = convertFromMarkdown(this.schema, text, this.markdownOptions);
|
||||||
this.schema,
|
|
||||||
text,
|
|
||||||
this.markdownOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
// assumes it returns a single block node
|
// assumes it returns a single block node
|
||||||
const content = doc.content.firstChild.content;
|
const content =
|
||||||
|
doc.content.firstChild.type.name === "paragraph"
|
||||||
|
? doc.content.firstChild.content
|
||||||
|
: doc.content.firstChild;
|
||||||
|
|
||||||
this.view.dispatch(
|
this.view.dispatch(
|
||||||
this.view.state.tr.replaceWith(sel.start, sel.end, content)
|
this.view.state.tr.replaceWith(sel.start, sel.end, content)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertBlock(block) {
|
insertBlock(block) {
|
||||||
const doc = await convertFromMarkdown(this.schema, block);
|
const doc = convertFromMarkdown(this.schema, block);
|
||||||
|
|
||||||
this.view.dispatch(
|
this.view.dispatch(
|
||||||
this.view.state.tr.replaceWith(
|
this.view.state.tr.replaceWith(
|
||||||
|
@ -161,6 +165,8 @@ export default class TextManipulation {
|
||||||
command = isInside(applyListMap[exampleKey])
|
command = isInside(applyListMap[exampleKey])
|
||||||
? lift
|
? lift
|
||||||
: wrapIn(applyListMap[exampleKey]);
|
: wrapIn(applyListMap[exampleKey]);
|
||||||
|
} else {
|
||||||
|
// TODO(renato): fallback to markdown parsing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -220,31 +226,30 @@ export default class TextManipulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
paste(e) {
|
paste() {
|
||||||
// TODO
|
// Intentionally no-op
|
||||||
console.log("paste");
|
// Pasting markdown is being handled by the markdown-paste extension
|
||||||
// let { clipboard, canPasteHtml, canUpload } = clipboardHelpers(e, {
|
// Pasting an url on top of a text is being handled by the link extension
|
||||||
// siteSettings: this.siteSettings,
|
|
||||||
// canUpload: true,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// console.log(clipboard);
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
e.stopImmediatePropagation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectText() {
|
selectText(from, length, opts) {
|
||||||
// TODO
|
const tr = this.view.state.tr.setSelection(
|
||||||
|
new this.view.state.selection.constructor(
|
||||||
|
this.view.state.doc.resolve(from),
|
||||||
|
this.view.state.doc.resolve(from + length)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.scroll) {
|
||||||
|
tr.scrollIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.view.dispatch(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
inCodeBlock() {
|
inCodeBlock() {
|
||||||
return (
|
return this.autocompleteHandler.inCodeBlock();
|
||||||
this.view.state.selection.$from.parent.type ===
|
|
||||||
this.schema.nodes.code_block
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -287,7 +292,7 @@ class AutocompleteHandler {
|
||||||
* @param {number} end
|
* @param {number} end
|
||||||
* @param {String} term
|
* @param {String} term
|
||||||
*/
|
*/
|
||||||
async 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;
|
||||||
|
@ -307,7 +312,7 @@ class AutocompleteHandler {
|
||||||
// );
|
// );
|
||||||
// }
|
// }
|
||||||
|
|
||||||
const doc = await convertFromMarkdown(this.schema, term);
|
const doc = convertFromMarkdown(this.schema, term);
|
||||||
|
|
||||||
const tr = this.view.state.tr.replaceWith(
|
const tr = this.view.state.tr.replaceWith(
|
||||||
from,
|
from,
|
||||||
|
@ -355,8 +360,10 @@ class AutocompleteHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
inCodeBlock() {
|
inCodeBlock() {
|
||||||
// TODO
|
return (
|
||||||
return false;
|
this.view.state.selection.$from.parent.type ===
|
||||||
|
this.schema.nodes.code_block
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -418,7 +425,7 @@ class PlaceholderHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async success(file, markdown) {
|
success(file, markdown) {
|
||||||
let nodeToReplace = null;
|
let nodeToReplace = null;
|
||||||
this.view.state.doc.descendants((node, pos) => {
|
this.view.state.doc.descendants((node, pos) => {
|
||||||
if (
|
if (
|
||||||
|
@ -433,7 +440,7 @@ class PlaceholderHandler {
|
||||||
});
|
});
|
||||||
|
|
||||||
// keeping compatibility with plugins that change the image node via markdown
|
// keeping compatibility with plugins that change the image node via markdown
|
||||||
const doc = await convertFromMarkdown(this.schema, markdown);
|
const doc = convertFromMarkdown(this.schema, markdown);
|
||||||
|
|
||||||
this.view.dispatch(
|
this.view.dispatch(
|
||||||
this.view.state.tr.replaceWith(
|
this.view.state.tr.replaceWith(
|
||||||
|
|
|
@ -44,10 +44,21 @@ function markInputRule(regexp, markType, getAttrs) {
|
||||||
tr.delete(start, textStart);
|
tr.delete(start, textStart);
|
||||||
}
|
}
|
||||||
end = start + match[1].length;
|
end = start + match[1].length;
|
||||||
|
|
||||||
|
tr.addMark(start, end, markType.create(attrs));
|
||||||
|
tr.removeStoredMark(markType);
|
||||||
|
} else {
|
||||||
|
tr.delete(start, end);
|
||||||
|
tr.insertText(" ");
|
||||||
|
tr.addMark(start, start + 1, markType.create(attrs));
|
||||||
|
tr.removeStoredMark(markType);
|
||||||
|
tr.insertText(" ");
|
||||||
|
|
||||||
|
tr.setSelection(
|
||||||
|
state.selection.constructor.create(tr.doc, start, start + 1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
tr.addMark(start, end, markType.create(attrs));
|
|
||||||
tr.removeStoredMark(markType);
|
|
||||||
return tr;
|
return tr;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -82,23 +93,9 @@ export function buildInputRules(schema) {
|
||||||
const markInputRules = [
|
const markInputRules = [
|
||||||
markInputRule(/\*\*([^*]+)\*\*$/, marks.strong),
|
markInputRule(/\*\*([^*]+)\*\*$/, marks.strong),
|
||||||
markInputRule(/(?<=^|\s)__([^_]+)__$/, marks.strong),
|
markInputRule(/(?<=^|\s)__([^_]+)__$/, marks.strong),
|
||||||
|
|
||||||
markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, marks.em),
|
markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, marks.em),
|
||||||
markInputRule(/(?<=^|\s)_([^_]+)_$/, marks.em),
|
markInputRule(/(?<=^|\s)_([^_]+)_$/, marks.em),
|
||||||
|
|
||||||
markInputRule(
|
|
||||||
/\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/,
|
|
||||||
marks.link,
|
|
||||||
(match) => {
|
|
||||||
return { href: match[2], title: match[3] };
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
markInputRule(/`([^`]+)`$/, marks.code),
|
markInputRule(/`([^`]+)`$/, marks.code),
|
||||||
|
|
||||||
markInputRule(/~~([^~]+)~~$/, marks.strikethrough),
|
|
||||||
|
|
||||||
markInputRule(/\[u]([^[]+)\[\/u]$/, marks.underline),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
rules = rules
|
rules = rules
|
||||||
|
@ -114,7 +111,7 @@ export function buildInputRules(schema) {
|
||||||
|
|
||||||
function processInputRule(inputRule, schema) {
|
function processInputRule(inputRule, schema) {
|
||||||
if (inputRule instanceof Array) {
|
if (inputRule instanceof Array) {
|
||||||
return inputRule.map(processInputRule);
|
return inputRule.map((rule) => processInputRule(rule, schema));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputRule instanceof Function) {
|
if (inputRule instanceof Function) {
|
||||||
|
|
|
@ -57,6 +57,10 @@ export function buildKeymap(schema, initialKeymap = {}, suppressKeys) {
|
||||||
bind("Mod-`", toggleMark(type));
|
bind("Mod-`", toggleMark(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((type = schema.marks.underline)) {
|
||||||
|
bind("Mod-u", toggleMark(type));
|
||||||
|
}
|
||||||
|
|
||||||
if ((type = schema.nodes.blockquote)) {
|
if ((type = schema.nodes.blockquote)) {
|
||||||
bind("Ctrl->", wrapIn(type));
|
bind("Ctrl->", wrapIn(type));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,96 +1,146 @@
|
||||||
.composer-toggle-switch {
|
.composer-toggle-switch {
|
||||||
--toggle-switch-width: 40px;
|
$root: &;
|
||||||
|
|
||||||
|
--toggle-switch-width: 48px;
|
||||||
--toggle-switch-height: 24px;
|
--toggle-switch-height: 24px;
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&__checkbox-slider {
|
||||||
|
outline: 2px solid var(--tertiary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&__checkbox-slider {
|
||||||
|
background-color: var(--primary-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox[aria-checked="true"]:not([disabled])
|
||||||
|
+ #{$root}__checkbox-slider {
|
||||||
|
background-color: var(--tertiary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
&__button {
|
&__label {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox {
|
||||||
|
position: absolute;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus {
|
||||||
outline: none;
|
+ #{$root}__checkbox-slider {
|
||||||
|
outline: 2px solid var(--tertiary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outline should show only when tabbing, not clicking
|
||||||
|
&:not(:focus-visible) {
|
||||||
|
+ #{$root}__checkbox-slider {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&[disabled] {
|
}
|
||||||
opacity: 0.5;
|
|
||||||
|
&__checkbox[aria-checked="true"] + &__checkbox-slider::before {
|
||||||
|
left: calc(var(--toggle-switch-width) - var(--toggle-switch-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox[disabled] + &__checkbox-slider {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&::before {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slider {
|
&__checkbox-slider {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--primary-low);
|
cursor: pointer;
|
||||||
|
background: var(--primary-low-mid);
|
||||||
width: var(--toggle-switch-width);
|
width: var(--toggle-switch-width);
|
||||||
height: var(--toggle-switch-height);
|
height: var(--toggle-switch-height);
|
||||||
position: relative;
|
position: relative;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border-radius: 0.25em;
|
transition: background 0.25s;
|
||||||
|
|
||||||
:focus-visible & {
|
|
||||||
outline: 2px solid var(--tertiary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
background-color: var(--tertiary-low);
|
|
||||||
width: calc(var(--toggle-switch-height) - 2px);
|
|
||||||
height: calc(var(--toggle-switch-height) - 4px);
|
|
||||||
top: 2px;
|
|
||||||
transition: left 0.25s, right 0.25s;
|
|
||||||
border-radius: 0.25em;
|
|
||||||
box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
.--markdown & {
|
|
||||||
left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.--rte & {
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
transition-duration: 0ms;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__left-icon,
|
&__left-icon {
|
||||||
&__right-icon {
|
|
||||||
display: inline-block;
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.25s left 0.25s, right 0.25s;
|
transition: opacity 0.25s;
|
||||||
height: 100%;
|
|
||||||
width: calc(var(--toggle-switch-height) - 2px);
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
transition-duration: 0ms;
|
transition-duration: 0ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.--markdown & {
|
|
||||||
left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.--rte & {
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.--active {
|
&.--active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.d-icon {
|
& .d-icon {
|
||||||
font-size: var(--font-down-1);
|
font-size: var(--font-down-1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
vertical-align: text-bottom;
|
left: 5px;
|
||||||
|
top: 6px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__right-icon {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition-duration: 0ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .d-icon {
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
color: var(--primary);
|
||||||
|
right: 5px;
|
||||||
|
top: 6px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.--active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox-slider::before,
|
||||||
|
&__checkbox-slider::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__checkbox-slider::before {
|
||||||
|
background-color: var(--tertiary-low);
|
||||||
|
width: var(--toggle-switch-height);
|
||||||
|
height: var(--toggle-switch-height);
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transition: left 0.25s;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition-duration: 0ms;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,10 @@
|
||||||
outline: 0;
|
outline: 0;
|
||||||
padding: 0 0.625rem;
|
padding: 0 0.625rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
|
@ -117,12 +121,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.d-editor__code-block {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.d-editor__code-lang-select {
|
.d-editor__code-lang-select {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.25rem;
|
right: 0.25rem;
|
||||||
top: -0.6rem;
|
top: -0.6rem;
|
||||||
border: 1px solid var(--primary-low);
|
border: 1px solid var(--primary-low);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--d-border-radius);
|
||||||
background-color: var(--primary-very-low);
|
background-color: var(--primary-very-low);
|
||||||
color: var(--primary-medium);
|
color: var(--primary-medium);
|
||||||
font-size: var(--font-down-1-rem);
|
font-size: var(--font-down-1-rem);
|
||||||
|
|
|
@ -7,7 +7,7 @@ in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [1.40.0] - 2024-12-16
|
## [1.40.0] - 2025-01-02
|
||||||
|
|
||||||
- Added `registerRichEditorExtension` which allows plugins/TCs to register an extension for the rich text editor.
|
- Added `registerRichEditorExtension` which allows plugins/TCs to register an extension for the rich text editor.
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
export default {
|
export default {
|
||||||
nodeSpec: {
|
nodeSpec: {
|
||||||
details: {
|
details: {
|
||||||
|
attrs: { open: { default: true } },
|
||||||
content: "summary block+",
|
content: "summary block+",
|
||||||
group: "block",
|
group: "block",
|
||||||
defining: true,
|
defining: true,
|
||||||
parseDOM: [{ tag: "details" }],
|
parseDOM: [{ tag: "details" }],
|
||||||
toDOM: () => ["details", { open: true }, 0],
|
toDOM: (node) => ["details", { open: node.attrs.open || undefined }, 0],
|
||||||
},
|
},
|
||||||
summary: {
|
summary: {
|
||||||
content: "inline*",
|
content: "inline*",
|
||||||
group: "block",
|
|
||||||
parseDOM: [{ tag: "summary" }],
|
parseDOM: [{ tag: "summary" }],
|
||||||
toDOM: () => ["summary", 0],
|
toDOM: () => ["summary", 0],
|
||||||
},
|
},
|
||||||
|
@ -17,7 +17,9 @@ export default {
|
||||||
parse: {
|
parse: {
|
||||||
bbcode(state, token) {
|
bbcode(state, token) {
|
||||||
if (token.tag === "details") {
|
if (token.tag === "details") {
|
||||||
state.openNode(state.schema.nodes.details);
|
state.openNode(state.schema.nodes.details, {
|
||||||
|
open: token.attrGet("open") !== null,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,12 +32,31 @@ export default {
|
||||||
serializeNode: {
|
serializeNode: {
|
||||||
details(state, node) {
|
details(state, node) {
|
||||||
state.renderContent(node);
|
state.renderContent(node);
|
||||||
state.write("[/details]\n");
|
state.write("[/details]\n\n");
|
||||||
},
|
},
|
||||||
summary(state, node) {
|
summary(state, node, parent) {
|
||||||
state.write("[details=");
|
state.write('[details="');
|
||||||
state.renderContent(node);
|
node.content.forEach(
|
||||||
state.write("]\n");
|
(child) =>
|
||||||
|
child.text &&
|
||||||
|
state.text(child.text.replace(/"/g, "“"), state.inAutolink)
|
||||||
|
);
|
||||||
|
state.write(`"${parent.attrs.open ? " open" : ""}]\n`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
props: {
|
||||||
|
handleClickOn(view, pos, node, nodePos) {
|
||||||
|
if (node.type.name === "summary") {
|
||||||
|
const details = view.state.doc.nodeAt(nodePos - 1);
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setNodeMarkup(nodePos - 1, null, {
|
||||||
|
open: !details.attrs.open,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export default {
|
export default {
|
||||||
// TODO 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: {
|
||||||
attrs: { date: {}, time: {}, timezone: { default: null } },
|
attrs: { date: {}, time: {}, timezone: { default: null } },
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { createPopper } from "@popperjs/core";
|
import { createPopper } from "@popperjs/core";
|
||||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||||
|
import richEditorExtension from "../lib/rich-editor-extension";
|
||||||
|
|
||||||
let inlineFootnotePopper;
|
let inlineFootnotePopper;
|
||||||
|
|
||||||
|
@ -128,6 +129,8 @@ export default {
|
||||||
tooltip?.removeAttribute("data-show");
|
tooltip?.removeAttribute("data-show");
|
||||||
tooltip?.removeAttribute("data-footnote-id");
|
tooltip?.removeAttribute("data-footnote-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.registerRichEditorExtension(richEditorExtension);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export default {};
|
|
@ -1,4 +1,5 @@
|
||||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
import richEditorExtension from "../../lib/rich-editor-extension";
|
||||||
import PollUiBuilder from "../components/modal/poll-ui-builder";
|
import PollUiBuilder from "../components/modal/poll-ui-builder";
|
||||||
|
|
||||||
function initializePollUIBuilder(api) {
|
function initializePollUIBuilder(api) {
|
||||||
|
@ -21,6 +22,8 @@ function initializePollUIBuilder(api) {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.registerRichEditorExtension(richEditorExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
export default {
|
||||||
|
nodeSpec: {
|
||||||
|
poll: {
|
||||||
|
attrs: {
|
||||||
|
type: {},
|
||||||
|
results: {},
|
||||||
|
public: {},
|
||||||
|
name: {},
|
||||||
|
chartType: {},
|
||||||
|
close: { default: null },
|
||||||
|
groups: { default: null },
|
||||||
|
max: { default: null },
|
||||||
|
min: { default: null },
|
||||||
|
},
|
||||||
|
content: "poll_container poll_info",
|
||||||
|
group: "block",
|
||||||
|
draggable: true,
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: "div.poll",
|
||||||
|
getAttrs: (dom) => ({
|
||||||
|
type: dom.getAttribute("data-poll-type"),
|
||||||
|
results: dom.getAttribute("data-poll-results"),
|
||||||
|
public: dom.getAttribute("data-poll-public"),
|
||||||
|
name: dom.getAttribute("data-poll-name"),
|
||||||
|
chartType: dom.getAttribute("data-poll-chart-type"),
|
||||||
|
close: dom.getAttribute("data-poll-close"),
|
||||||
|
groups: dom.getAttribute("data-poll-groups"),
|
||||||
|
max: dom.getAttribute("data-poll-max"),
|
||||||
|
min: dom.getAttribute("data-poll-min"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: (node) => [
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: "poll",
|
||||||
|
"data-poll-type": node.attrs.type,
|
||||||
|
"data-poll-results": node.attrs.results,
|
||||||
|
"data-poll-public": node.attrs.public,
|
||||||
|
"data-poll-name": node.attrs.name,
|
||||||
|
"data-poll-chart-type": node.attrs.chartType,
|
||||||
|
"data-poll-close": node.attrs.close,
|
||||||
|
"data-poll-groups": node.attrs.groups,
|
||||||
|
"data-poll-max": node.attrs.max,
|
||||||
|
"data-poll-min": node.attrs.min,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
poll_container: {
|
||||||
|
content: "heading? bullet_list",
|
||||||
|
group: "block",
|
||||||
|
parseDOM: [{ tag: "div.poll-container" }],
|
||||||
|
toDOM: () => ["div", { class: "poll-container" }, 0],
|
||||||
|
},
|
||||||
|
poll_info: {
|
||||||
|
content: "inline*",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
selectable: false,
|
||||||
|
parseDOM: [{ tag: "div.poll-info" }],
|
||||||
|
toDOM: () => ["div", { class: "poll-info", contentEditable: false }, 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parse: {
|
||||||
|
poll: {
|
||||||
|
block: "poll",
|
||||||
|
getAttrs: (token) => ({
|
||||||
|
type: token.attrGet("data-poll-type"),
|
||||||
|
results: token.attrGet("data-poll-results"),
|
||||||
|
public: token.attrGet("data-poll-public"),
|
||||||
|
name: token.attrGet("data-poll-name"),
|
||||||
|
chartType: token.attrGet("data-poll-chart-type"),
|
||||||
|
close: token.attrGet("data-poll-close"),
|
||||||
|
groups: token.attrGet("data-poll-groups"),
|
||||||
|
max: token.attrGet("data-poll-max"),
|
||||||
|
min: token.attrGet("data-poll-min"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
poll_container: { block: "poll_container" },
|
||||||
|
poll_title: { block: "heading" },
|
||||||
|
poll_info: { block: "poll_info" },
|
||||||
|
poll_info_counts: { ignore: true },
|
||||||
|
poll_info_counts_count: { ignore: true },
|
||||||
|
poll_info_number: { ignore: true },
|
||||||
|
poll_info_label: { ignore: true },
|
||||||
|
},
|
||||||
|
serializeNode: {
|
||||||
|
poll(state, node) {
|
||||||
|
const attrs = Object.entries(node.attrs)
|
||||||
|
.map(([key, value]) => (value ? ` ${key}="${value}"` : ""))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
state.write(`[poll${attrs}]\n`);
|
||||||
|
state.renderContent(node);
|
||||||
|
state.write("[/poll]\n\n");
|
||||||
|
},
|
||||||
|
poll_container(state, node) {
|
||||||
|
state.renderContent(node);
|
||||||
|
},
|
||||||
|
poll_info() {},
|
||||||
|
},
|
||||||
|
};
|
|
@ -494,6 +494,71 @@ div.poll-outer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.d-editor__editable {
|
||||||
|
.poll {
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
p {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li:before {
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-poll-type="multiple"],
|
||||||
|
&[data-poll-type="ranked_choice"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-poll-type="multiple"] {
|
||||||
|
li:before {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-poll-type="ranked_choice"] {
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
&:before {
|
||||||
|
mask-image: svg-uri(
|
||||||
|
'<svg width="0.75em" height="0.75em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M201.4 374.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 306.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z"/></svg>'
|
||||||
|
);
|
||||||
|
z-index: 1;
|
||||||
|
width: 0.75em;
|
||||||
|
margin-right: 0.75em;
|
||||||
|
left: 0.2em;
|
||||||
|
background: var(--primary-high);
|
||||||
|
border-radius: var(--d-button-border-radius);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
height: 1.125em;
|
||||||
|
width: 1.125em;
|
||||||
|
background: var(--primary-low);
|
||||||
|
border-radius: var(--d-button-border-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hides 0 vote count in crawler and print view
|
// hides 0 vote count in crawler and print view
|
||||||
body.crawler {
|
body.crawler {
|
||||||
.poll {
|
.poll {
|
||||||
|
@ -503,3 +568,9 @@ body.crawler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.d-editor__editable .poll {
|
||||||
|
border: 1px solid var(--primary-low);
|
||||||
|
border-radius: var(--d-border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
addTagDecorateCallback,
|
addTagDecorateCallback,
|
||||||
} from "discourse/lib/to-markdown";
|
} from "discourse/lib/to-markdown";
|
||||||
import applySpoiler from "discourse/plugins/spoiler-alert/lib/apply-spoiler";
|
import applySpoiler from "discourse/plugins/spoiler-alert/lib/apply-spoiler";
|
||||||
|
import richEditorExtension from "discourse/plugins/spoiler-alert/lib/rich-editor-extension";
|
||||||
|
|
||||||
function spoil(element) {
|
function spoil(element) {
|
||||||
element.querySelectorAll(".spoiler").forEach((spoiler) => {
|
element.querySelectorAll(".spoiler").forEach((spoiler) => {
|
||||||
|
@ -45,6 +46,8 @@ export function initializeSpoiler(api) {
|
||||||
return text.trim();
|
return text.trim();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.registerRichEditorExtension(richEditorExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
const INLINE_NODES = ["inline_spoiler", "spoiler"];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
nodeSpec: {
|
||||||
|
spoiler: {
|
||||||
|
attrs: { blurred: { default: true } },
|
||||||
|
group: "block",
|
||||||
|
content: "block+",
|
||||||
|
defining: true,
|
||||||
|
parseDOM: [{ tag: "div.spoiled" }],
|
||||||
|
toDOM: (node) => [
|
||||||
|
"div",
|
||||||
|
{ class: `spoiled ${node.attrs.blurred ? "spoiler-blurred" : ""}` },
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
inline_spoiler: {
|
||||||
|
attrs: { blurred: { default: true } },
|
||||||
|
group: "inline",
|
||||||
|
inline: true,
|
||||||
|
content: "inline*",
|
||||||
|
parseDOM: [{ tag: "span.spoiled" }],
|
||||||
|
toDOM: (node) => [
|
||||||
|
"span",
|
||||||
|
{ class: `spoiled ${node.attrs.blurred ? "spoiler-blurred" : ""}` },
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parse: {
|
||||||
|
bbcode_spoiler: { block: "inline_spoiler" },
|
||||||
|
wrap_bbcode(state, token) {
|
||||||
|
if (token.nesting === 1 && token.attrGet("class") === "spoiler") {
|
||||||
|
state.openNode(state.schema.nodes.spoiler);
|
||||||
|
} else if (token.nesting === -1) {
|
||||||
|
state.closeNode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serializeNode: {
|
||||||
|
spoiler(state, node) {
|
||||||
|
state.write("[spoiler]\n");
|
||||||
|
state.renderContent(node);
|
||||||
|
state.write("[/spoiler]\n\n");
|
||||||
|
},
|
||||||
|
inline_spoiler(state, node) {
|
||||||
|
state.write("[spoiler]");
|
||||||
|
state.renderInline(node);
|
||||||
|
state.write("[/spoiler]");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
props: {
|
||||||
|
handleClickOn(view, pos, node, nodePos, event, direct) {
|
||||||
|
if (INLINE_NODES.includes(node.type.name)) {
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setNodeMarkup(nodePos, null, {
|
||||||
|
blurred: !node.attrs.blurred,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -52,3 +52,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.d-editor__editable {
|
||||||
|
.spoiled:not(.spoiler-blurred) {
|
||||||
|
box-shadow: 0 0 4px 4px rgba(var(--primary-rgb), 0.2);
|
||||||
|
inline-size: max-content;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue