DEV: prosemirror

This commit is contained in:
Renato Atilio 2024-11-04 14:13:43 -03:00
parent e22c77a1bc
commit f46da69f09
No known key found for this signature in database
GPG Key ID: CBF93DCB5CBCA1A5
34 changed files with 891 additions and 248 deletions

View File

@ -2,43 +2,33 @@ import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
const ComposerToggleSwitch = <template>
<div
class="{{concatClass
'composer-toggle-switch'
(if @state '--rte' '--markdown')
}}"
>
{{! template-lint-disable no-redundant-role }}
<button
class="composer-toggle-switch__button"
type="button"
role="switch"
aria-pressed={{if @state "true" "false"}}
...attributes
>
<div class="composer-toggle-switch">
<label class="composer-toggle-switch__label">
{{! template-lint-disable no-redundant-role }}
<button
class="composer-toggle-switch__checkbox"
type="button"
role="switch"
aria-checked={{if @state "true" "false"}}
...attributes
></button>
{{! template-lint-enable no-redundant-role }}
<span class="composer-toggle-switch__slider" focusable="false">
<span class="composer-toggle-switch__checkbox-slider">
<span
class={{concatClass
"composer-toggle-switch__left-icon"
(unless @state "--active")
}}
aria-hidden="true"
focusable="false"
>{{icon "fab-markdown"}}</span>
<span
class={{concatClass
"composer-toggle-switch__right-icon"
(if @state "--active")
}}
aria-hidden="true"
focusable="false"
>{{icon "a"}}</span>
</span>
</button>
</label>
</div>
</template>;

View File

@ -9,13 +9,13 @@
>
<div class="d-editor-button-bar" role="toolbar">
{{#if this.siteSettings.experimental_rich_editor}}
<Composer::ToggleSwitch
@state={{this.isRichEditorEnabled}}
{{on "click" this.toggleRichEditor}}
/>
{{/if}}
<Composer::ToggleSwitch
@state={{this.isRichEditorEnabled}}
{{on "click" this.toggleRichEditor}}
/>
{{/if}}
{{#each this.toolbar.groups as |group|}}
{{#each this.toolbar.groups as |group|}}
{{#each group.buttons as |b|}}
{{#if (b.condition this)}}
{{#if b.popupMenu}}
@ -56,6 +56,8 @@
@change={{this.onChange}}
@focusIn={{this.handleFocusIn}}
@focusOut={{this.handleFocusOut}}
@categoryId={{@categoryId}}
@topicId={{@topicId}}
@id={{this.textAreaId}}
/>
<PopupInputTip @validation={{this.validation}} />

View File

@ -11,12 +11,14 @@ import {
getNodeViews,
getPlugins,
} 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 { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import * as ProsemirrorState from "prosemirror-state";
import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { bind } from "discourse-common/utils/decorators";
@ -54,18 +56,20 @@ export default class ProsemirrorEditor extends Component {
this.plugins ??= [
buildInputRules(this.schema),
// TODO buildPasteRules(),
keymap(buildKeymap(this.schema, keymapFromArgs)),
keymap(baseKeymap),
dropCursor({ color: "var(--primary)" }),
gapCursor(),
history(),
placeholder(this.args.placeholder),
createHighlight(),
...getPlugins().map((plugin) =>
// can be either a function that receives the Plugin class,
// or a plugin spec to be passed directly to the Plugin constructor
typeof plugin === "function" ? plugin(Plugin) : new Plugin(plugin)
typeof plugin === "function"
? plugin({
...ProsemirrorState,
...ProsemirrorModel,
...ProsemirrorView,
})
: new Plugin(plugin)
),
];
@ -75,6 +79,10 @@ export default class ProsemirrorEditor extends Component {
});
this.view = new EditorView(this.rootElement, {
discourse: {
topicId: this.args.topicId,
categoryId: this.args.categoryId,
},
nodeViews: this.args.nodeViews ?? getNodeViews(),
state: this.state,
attributes: { class: "d-editor-input d-editor__editable" },
@ -98,8 +106,7 @@ export default class ProsemirrorEditor extends Component {
},
},
handleKeyDown: (view, event) => {
// this happens before the autocomplete event, so we check if it's open
// TODO(renato): find a better way to handle these events, or just a better check
// skip the event if it's an Enter keypress and the autocomplete is open
return (
event.key === "Enter" && !!document.querySelector(".autocomplete")
);
@ -114,14 +121,13 @@ export default class ProsemirrorEditor extends Component {
this.destructor = this.args.onSetup(this.textManipulation);
await this.convertFromValue();
this.convertFromValue();
}
@bind
async convertFromValue() {
const doc = await convertFromMarkdown(this.schema, this.args.value);
convertFromValue() {
const doc = convertFromMarkdown(this.schema, this.args.value);
// doc.check();
// console.log("Resulting doc:", doc);
const tr = this.state.tr

View File

@ -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;
}
},
},
},
};

View File

@ -30,7 +30,7 @@ export default {
inputRules: [
{
match: /#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101}) $/,
match: /(?<=^|\W)#([\u00C0-\u1FFF\u2C00-\uD7FF\w:-]{1,101}) $/,
handler: (state, match, start, end) =>
state.selection.$from.nodeBefore?.type !== state.schema.nodes.hashtag &&
state.tr.replaceWith(start, end, [

View File

@ -0,0 +1,6 @@
export default {
nodeSpec: {
// TODO(renato): html_block should be like a passthrough code block
html_block: { block: "paragraph", noCloseToken: true },
},
};

View File

@ -19,6 +19,8 @@ const ALLOWED_INLINE = [
"mark",
];
const ALL_ALLOWED_TAGS = [...Object.keys(HTML_INLINE_MARKS), ...ALLOWED_INLINE];
export default {
nodeSpec: {
// 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)) {
state.openNode(state.schema.nodeType("html_inline"), {
state.openNode(state.schema.nodeType.html_inline, {
tag: tagName,
});
}
@ -77,16 +79,37 @@ export default {
},
},
inputRules: {
match: new RegExp(`<(${ALLOWED_INLINE.join("|")})>`),
match: new RegExp(`<(${ALL_ALLOWED_TAGS.join("|")})>$`, "i"),
handler: (state, match, start, end) => {
const tag = match[1];
// TODO not finished
state.tr.replaceWith(
start,
end,
state.schema.nodes.html_inline.create({ tag })
const markName = HTML_INLINE_MARKS[tag];
const tr = state.tr;
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;
},
},
};

View File

@ -106,7 +106,7 @@ export default {
},
},
plugins: (Plugin) => {
plugins: ({ Plugin }) => {
const shortUrlResolver = new Plugin({
state: {
init() {

View File

@ -1,35 +1,43 @@
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import codeLangSelector from "./code-lang-selector";
import emojiExtension from "./emoji";
import hashtagExtension from "./hashtag";
import headingExtension from "./heading";
import htmlInlineExtension from "./html-inline";
import imageExtension from "./image";
import linkExtension from "./link";
import mentionExtension from "./mention";
import quoteExtension from "./quote";
import strikethroughExtension from "./strikethrough";
import tableExtension from "./table";
import codeBlock from "./code-block";
import emoji from "./emoji";
import hashtag from "./hashtag";
import heading from "./heading";
import htmlBlock from "./html-block";
import htmlInline from "./html-inline";
import image from "./image";
import link from "./link";
import markdownPaste from "./markdown-paste";
import mention from "./mention";
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 underlineExtension from "./underline";
import underline from "./underline";
const defaultExtensions = [
emojiExtension,
emoji,
// image must be after emoji
imageExtension,
hashtagExtension,
mentionExtension,
strikethroughExtension,
underlineExtension,
htmlInlineExtension,
linkExtension,
headingExtension,
image,
hashtag,
mention,
strikethrough,
underline,
htmlInline,
htmlBlock,
link,
heading,
codeBlock,
quote,
onebox,
trailingParagraph,
typographerReplacements,
codeLangSelector,
quoteExtension,
markdownPaste,
// table must be last
tableExtension,
table,
];
defaultExtensions.forEach(registerRichEditorExtension);

View File

@ -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 {
inputRules: [
{
match: HTTP_MAILTO_REGEX,
handler: (state, match, start, end) => {
const markType = state.schema.marks.link;
const resolvedStart = state.doc.resolve(start);
if (!resolvedStart.parent.type.allowsMarkType(markType)) {
return null;
// []() replacement
({ schema, markInputRule }) =>
markInputRule(
/\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/,
schema.marks.link,
(match) => {
return { href: match[2], title: match[3] };
}
const link = match[0].substring(0, match[0].length - 1);
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);
},
},
),
// TODO(renato): auto-linkify when typing (https://github.com/markdown-it/markdown-it/blob/master/lib/rules_inline/autolink.mjs)
],
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);
},
},
}),
};

View File

@ -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));
},
},
});
},
};

View File

@ -34,8 +34,8 @@ export default {
inputRules: [
{
// TODO: pass unicodeUsernames?
match: new RegExp(`(${mentionRegex().source}) $`),
// TODO(renato): pass unicodeUsernames?
match: new RegExp(`(?<=^|\\W)(${mentionRegex().source}) $`),
handler: (state, match, start, end) =>
state.selection.$from.nodeBefore?.type !== state.schema.nodes.mention &&
state.tr.replaceWith(start, end, [

View File

@ -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;
}

View File

@ -83,7 +83,7 @@ export default {
state.renderContent(n);
}
});
state.write("[/quote]\n");
state.write("[/quote]\n\n");
},
quote_title() {},
},

View File

@ -15,6 +15,8 @@ export default {
},
},
},
inputRules: ({ schema, markInputRule }) =>
markInputRule(/~~([^~]+)~~$/, schema.marks.strikethrough),
parse: {
s: { mark: "strikethrough" },
bbcode_s: { mark: "strikethrough" },

View File

@ -13,7 +13,7 @@
export default {
nodeSpec: {
table: {
content: "(table_head | table_body)+",
content: "table_head table_body",
group: "block",
tableRole: "table",
isolating: true,
@ -61,7 +61,15 @@ export default {
},
],
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: {

View File

@ -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;
},
},
});
},
};

View File

@ -7,6 +7,8 @@ export default {
parseDOM: [{ tag: "u" }],
},
},
inputRules: ({ schema, markInputRule }) =>
markInputRule(/\[u]$/, schema.marks.underline),
parse: {
bbcode_u: { mark: "underline" },
},

View File

@ -1,49 +1,56 @@
import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown";
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:
// https://github.com/ProseMirror/prosemirror-markdown/issues/82
// 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 = {
...defaultMarkdownParser.tokens,
// Custom
bbcode_b: { mark: "strong" },
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 = {
softbreak: (state) => state.addText("\n"),
...parseFunctions,
softbreak: (state) => state.addNode(state.schema.nodes.hard_break),
};
export async function convertFromMarkdown(schema, text) {
const tokens = await parseAsync(text);
let parseOptions;
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);
const dummyTokenizer = { parse: () => tokens };
const parser = new MarkdownParser(schema, dummyTokenizer, parseTokens);
// workaround for custom (fn) handlers
for (const [key, callback] of Object.entries(postParseTokens)) {
parser.tokenHandlers[key] = callback;
}

View File

@ -93,26 +93,30 @@ export default class TextManipulation {
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) {
const doc = await convertFromMarkdown(
this.schema,
text,
this.markdownOptions
);
addText(sel, text, options) {
const doc = convertFromMarkdown(this.schema, text, this.markdownOptions);
// 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.state.tr.replaceWith(sel.start, sel.end, content)
);
}
async insertBlock(block) {
const doc = await convertFromMarkdown(this.schema, block);
insertBlock(block) {
const doc = convertFromMarkdown(this.schema, block);
this.view.dispatch(
this.view.state.tr.replaceWith(
@ -161,6 +165,8 @@ export default class TextManipulation {
command = isInside(applyListMap[exampleKey])
? lift
: wrapIn(applyListMap[exampleKey]);
} else {
// TODO(renato): fallback to markdown parsing
}
}
@ -220,31 +226,30 @@ export default class TextManipulation {
}
@bind
paste(e) {
// TODO
console.log("paste");
// let { clipboard, canPasteHtml, canUpload } = clipboardHelpers(e, {
// siteSettings: this.siteSettings,
// canUpload: true,
// });
// console.log(clipboard);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
paste() {
// Intentionally no-op
// Pasting markdown is being handled by the markdown-paste extension
// Pasting an url on top of a text is being handled by the link extension
}
selectText() {
// TODO
selectText(from, length, opts) {
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
inCodeBlock() {
return (
this.view.state.selection.$from.parent.type ===
this.schema.nodes.code_block
);
return this.autocompleteHandler.inCodeBlock();
}
/**
@ -287,7 +292,7 @@ class AutocompleteHandler {
* @param {number} end
* @param {String} term
*/
async replaceTerm({ start, end, term }) {
replaceTerm({ start, end, term }) {
const node = this.view.state.selection.$head.nodeBefore;
const from = this.view.state.selection.from - node.nodeSize + start;
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(
from,
@ -355,8 +360,10 @@ class AutocompleteHandler {
}
inCodeBlock() {
// TODO
return false;
return (
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;
this.view.state.doc.descendants((node, pos) => {
if (
@ -433,7 +440,7 @@ class PlaceholderHandler {
});
// 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.state.tr.replaceWith(

View File

@ -44,10 +44,21 @@ function markInputRule(regexp, markType, getAttrs) {
tr.delete(start, textStart);
}
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;
});
}
@ -82,23 +93,9 @@ export function buildInputRules(schema) {
const markInputRules = [
markInputRule(/\*\*([^*]+)\*\*$/, marks.strong),
markInputRule(/(?<=^|\s)__([^_]+)__$/, marks.strong),
markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, marks.em),
markInputRule(/(?<=^|\s)_([^_]+)_$/, marks.em),
markInputRule(
/\[([^\]]+)]\(([^)\s]+)(?:\s+[“"']([^“"']+)[”"'])?\)$/,
marks.link,
(match) => {
return { href: match[2], title: match[3] };
}
),
markInputRule(/`([^`]+)`$/, marks.code),
markInputRule(/~~([^~]+)~~$/, marks.strikethrough),
markInputRule(/\[u]([^[]+)\[\/u]$/, marks.underline),
];
rules = rules
@ -114,7 +111,7 @@ export function buildInputRules(schema) {
function processInputRule(inputRule, schema) {
if (inputRule instanceof Array) {
return inputRule.map(processInputRule);
return inputRule.map((rule) => processInputRule(rule, schema));
}
if (inputRule instanceof Function) {

View File

@ -57,6 +57,10 @@ export function buildKeymap(schema, initialKeymap = {}, suppressKeys) {
bind("Mod-`", toggleMark(type));
}
if ((type = schema.marks.underline)) {
bind("Mod-u", toggleMark(type));
}
if ((type = schema.nodes.blockquote)) {
bind("Ctrl->", wrapIn(type));
}

View File

@ -1,96 +1,146 @@
.composer-toggle-switch {
--toggle-switch-width: 40px;
$root: &;
--toggle-switch-width: 48px;
--toggle-switch-height: 24px;
height: 100%;
grid-column: span 2;
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;
align-items: center;
gap: 0.5rem;
&__button {
&__label {
position: relative;
display: inline-block;
cursor: pointer;
margin: 0;
}
&__checkbox {
position: absolute;
border: 0;
padding: 0;
background: transparent;
&:focus-visible {
outline: none;
&:focus {
+ #{$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;
}
}
&__slider {
&__checkbox-slider {
display: inline-block;
background: var(--primary-low);
cursor: pointer;
background: var(--primary-low-mid);
width: var(--toggle-switch-width);
height: var(--toggle-switch-height);
position: relative;
vertical-align: middle;
border-radius: 0.25em;
: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;
}
}
transition: background 0.25s;
}
&__left-icon,
&__right-icon {
display: inline-block;
position: absolute;
&__left-icon {
opacity: 0;
transition: opacity 0.25s left 0.25s, right 0.25s;
height: 100%;
width: calc(var(--toggle-switch-height) - 2px);
transition: opacity 0.25s;
@media (prefers-reduced-motion: reduce) {
transition-duration: 0ms;
}
.--markdown & {
left: 2px;
}
.--rte & {
right: 2px;
}
&.--active {
opacity: 1;
}
.d-icon {
& .d-icon {
font-size: var(--font-down-1);
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;
}
}
}

View File

@ -13,6 +13,10 @@
outline: 0;
padding: 0 0.625rem;
a {
pointer-events: none;
}
h1,
h2,
h3,
@ -117,12 +121,16 @@
}
}
.d-editor__code-block {
position: relative;
}
.d-editor__code-lang-select {
position: absolute;
right: 0.25rem;
top: -0.6rem;
border: 1px solid var(--primary-low);
border-radius: var(--border-radius);
border-radius: var(--d-border-radius);
background-color: var(--primary-very-low);
color: var(--primary-medium);
font-size: var(--font-down-1-rem);

View File

@ -1,15 +1,15 @@
export default {
nodeSpec: {
details: {
attrs: { open: { default: true } },
content: "summary block+",
group: "block",
defining: true,
parseDOM: [{ tag: "details" }],
toDOM: () => ["details", { open: true }, 0],
toDOM: (node) => ["details", { open: node.attrs.open || undefined }, 0],
},
summary: {
content: "inline*",
group: "block",
parseDOM: [{ tag: "summary" }],
toDOM: () => ["summary", 0],
},
@ -17,7 +17,9 @@ export default {
parse: {
bbcode(state, token) {
if (token.tag === "details") {
state.openNode(state.schema.nodes.details);
state.openNode(state.schema.nodes.details, {
open: token.attrGet("open") !== null,
});
return true;
}
@ -30,12 +32,31 @@ export default {
serializeNode: {
details(state, node) {
state.renderContent(node);
state.write("[/details]\n");
state.write("[/details]\n\n");
},
summary(state, node) {
state.write("[details=");
state.renderContent(node);
state.write("]\n");
summary(state, node, parent) {
state.write('[details="');
node.content.forEach(
(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;
}
},
},
},
};

View File

@ -1,5 +1,5 @@
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: {
local_date: {
attrs: { date: {}, time: {}, timezone: { default: null } },

View File

@ -1,6 +1,7 @@
import { createPopper } from "@popperjs/core";
import { withPluginApi } from "discourse/lib/plugin-api";
import { iconHTML } from "discourse-common/lib/icon-library";
import richEditorExtension from "../lib/rich-editor-extension";
let inlineFootnotePopper;
@ -128,6 +129,8 @@ export default {
tooltip?.removeAttribute("data-show");
tooltip?.removeAttribute("data-footnote-id");
});
api.registerRichEditorExtension(richEditorExtension);
});
},

View File

@ -0,0 +1 @@
export default {};

View File

@ -1,4 +1,5 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import richEditorExtension from "../../lib/rich-editor-extension";
import PollUiBuilder from "../components/modal/poll-ui-builder";
function initializePollUIBuilder(api) {
@ -21,6 +22,8 @@ function initializePollUIBuilder(api) {
);
},
});
api.registerRichEditorExtension(richEditorExtension);
}
export default {

View File

@ -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() {},
},
};

View File

@ -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
body.crawler {
.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;
}

View File

@ -4,6 +4,7 @@ import {
addTagDecorateCallback,
} from "discourse/lib/to-markdown";
import applySpoiler from "discourse/plugins/spoiler-alert/lib/apply-spoiler";
import richEditorExtension from "discourse/plugins/spoiler-alert/lib/rich-editor-extension";
function spoil(element) {
element.querySelectorAll(".spoiler").forEach((spoiler) => {
@ -45,6 +46,8 @@ export function initializeSpoiler(api) {
return text.trim();
}
});
api.registerRichEditorExtension(richEditorExtension);
}
export default {

View File

@ -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;
}
},
},
},
};

View File

@ -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;
}
}