DEV: prosemirror

This commit is contained in:
Renato Atilio 2024-11-04 14:13:43 -03:00
parent eb58623b11
commit adea7f535d
No known key found for this signature in database
GPG Key ID: CBF93DCB5CBCA1A5
52 changed files with 2943 additions and 55 deletions

View File

@ -14,9 +14,9 @@
// (r) (R) → ®
// (p) (P) -> §
let RARE_RE = /\+-|\.\.|\?\?\?\?|!!!!|,,|--|-->|<--|->|<-|<->|<-->/;
export const RARE_RE = /\+-|\.\.|\?\?\?\?|!!!!|,,|--|-->|<--|->|<-|<->|<-->/;
let SCOPED_ABBR_RE = /\((tm|pa)\)/gi;
export const SCOPED_ABBR_RE = /\((tm|pa)\)/gi;
let SCOPED_ABBR = {
pa: "¶",
tm: "™",
@ -32,11 +32,37 @@ function replaceScoped(inlineTokens) {
for (i = inlineTokens.length - 1; i >= 0; i--) {
token = inlineTokens[i];
if (token.type === "text") {
token.content = token.content.replace(SCOPED_ABBR_RE, replaceFn);
token.content = replaceScopedStr(token.content.replace);
}
}
}
export function replaceScopedStr(str) {
return str.replace(SCOPED_ABBR_RE, replaceFn);
}
export function replaceRareStr(str) {
return (
str
.replace(/\+-/g, "±")
// Custom arrows
.replace(/(^|\s)-{1,2}>(\s|$)/gm, "\u0020\u2192\u0020")
.replace(/(^|\s)<-{1,2}(\s|$)/gm, "\u0020\u2190\u0020")
.replace(/(^|\s)<-{1,2}>(\s|$)/gm, "\u0020\u2194\u0020")
// .., ..., ....... -> …
// but ?..... & !..... -> ?.. & !..
.replace(/\.{2,}/g, "…")
.replace(/([?!])…/g, "$1..")
.replace(/([?!]){4,}/g, "$1$1$1")
.replace(/,{2,}/g, ",")
// em-dash
.replace(/(^|[^-])---(?=[^-]|$)/gm, "$1\u2014")
// en-dash
.replace(/(^|\s)--(?=\s|$)/gm, "$1\u2013")
.replace(/(^|[^-\s])--(?=[^-\s]|$)/gm, "$1\u2013")
);
}
function replaceRare(inlineTokens) {
let i,
token,
@ -47,23 +73,7 @@ function replaceRare(inlineTokens) {
if (token.type === "text" && !inside_autolink) {
if (RARE_RE.test(token.content)) {
token.content = token.content
.replace(/\+-/g, "±")
// Custom arrows
.replace(/(^|\s)-{1,2}>(\s|$)/gm, "\u0020\u2192\u0020")
.replace(/(^|\s)<-{1,2}(\s|$)/gm, "\u0020\u2190\u0020")
.replace(/(^|\s)<-{1,2}>(\s|$)/gm, "\u0020\u2194\u0020")
// .., ..., ....... -> …
// but ?..... & !..... -> ?.. & !..
.replace(/\.{2,}/g, "…")
.replace(/([?!])…/g, "$1..")
.replace(/([?!]){4,}/g, "$1$1$1")
.replace(/,{2,}/g, ",")
// em-dash
.replace(/(^|[^-])---(?=[^-]|$)/gm, "$1\u2014")
// en-dash
.replace(/(^|\s)--(?=\s|$)/gm, "$1\u2013")
.replace(/(^|[^-\s])--(?=[^-\s]|$)/gm, "$1\u2013");
token.content = replaceRareStr(token.content);
}
}

View File

@ -298,15 +298,17 @@
</a>
{{/if}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.composer.togglePreview}}
aria-label={{i18n "composer.show_preview"}}
>
{{d-icon "desktop"}}
</a>
{{#if this.composer.allowPreview}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.composer.togglePreview}}
aria-label={{i18n "composer.show_preview"}}
>
{{d-icon "desktop"}}
</a>
{{/if}}
{{#if this.composer.showPreview}}
<DButton
@ -369,7 +371,7 @@
{{/if}}
</div>
{{#if this.site.desktopView}}
{{#if (and this.composer.allowPreview this.site.desktopView)}}
<DButton
@action={{this.composer.togglePreview}}
@translatedTitle={{this.composer.toggleText}}

View File

@ -208,6 +208,11 @@ export default class ComposerEditor extends Component {
this._throttledSyncEditorAndPreviewScroll
);
if (!this.site.mobileView) {
this.composer.set("showPreview", this.textManipulation.allowPreview);
}
this.composer.set("allowPreview", this.textManipulation.allowPreview);
// Focus on the body unless we have a title
if (!this.get("composer.model.canEditTitle")) {
this.textManipulation.putCursorAtEnd();
@ -862,14 +867,15 @@ export default class ComposerEditor extends Component {
@action
extraButtons(toolbar) {
toolbar.addButton({
id: "quote",
group: "fontStyles",
icon: "far-comment",
sendAction: this.composer.importQuote,
title: "composer.quote_post_title",
unshift: true,
});
// TODO remove this and all "importQuote" refs
// toolbar.addButton({
// id: "quote",
// group: "fontStyles",
// icon: "far-comment",
// sendAction: this.composer.importQuote,
// title: "composer.quote_post_title",
// unshift: true,
// });
if (
this.composer.allowUpload &&

View File

@ -0,0 +1,35 @@
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
const ComposerToggleSwitch = <template>
<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__checkbox-slider">
<span
class={{concatClass
"composer-toggle-switch__left-icon"
(unless @state "--active")
}}
>{{icon "fab-markdown"}}</span>
<span
class={{concatClass
"composer-toggle-switch__right-icon"
(if @state "--active")
}}
>{{icon "a"}}</span>
</span>
</label>
</div>
</template>;
export default ComposerToggleSwitch;

View File

@ -8,7 +8,14 @@
{{if this.isEditorFocused 'in-focus'}}"
>
<div class="d-editor-button-bar" role="toolbar">
{{#each this.toolbar.groups as |group|}}
{{#if this.siteSettings.experimental_rich_editor}}
<Composer::ToggleSwitch
@state={{this.isRichEditorEnabled}}
{{on "click" this.toggleRichEditor}}
/>
{{/if}}
{{#each this.toolbar.groups as |group|}}
{{#each group.buttons as |b|}}
{{#if (b.condition this)}}
{{#if b.popupMenu}}
@ -46,7 +53,7 @@
@value={{this.value}}
@placeholder={{this.placeholderTranslated}}
@disabled={{this.disabled}}
@change={{this.change}}
@change={{this.onChange}}
@focusIn={{this.handleFocusIn}}
@focusOut={{this.handleFocusOut}}
@id={{this.textAreaId}}

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import Component from "@ember/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
@ -56,8 +57,8 @@ export default class DEditor extends Component {
@service("emoji-store") emojiStore;
@service modal;
editorComponent = TextareaEditor;
textManipulation;
@tracked editorComponent;
@tracked textManipulation;
ready = false;
lastSel = null;
@ -73,10 +74,19 @@ export default class DEditor extends Component {
},
};
init() {
async init() {
super.init(...arguments);
this.register = getRegister(this);
if (
this.siteSettings.experimental_rich_editor &&
this.keyValueStore.get("d-editor-prefers-rich-editor") === "true"
) {
this.editorComponent = await this.loadProsemirrorEditor();
} else {
this.editorComponent = TextareaEditor;
}
}
@discourseComputed("placeholder")
@ -188,7 +198,12 @@ export default class DEditor extends Component {
@discourseComputed()
toolbar() {
const toolbar = new Toolbar(
this.getProperties("site", "siteSettings", "showLink", "capabilities")
this.getProperties(
"appEvents",
"siteSettings",
"showLink",
"capabilities"
)
);
toolbar.context = this;
@ -624,7 +639,7 @@ export default class DEditor extends Component {
@action
setupEditor(textManipulation) {
this.set("textManipulation", textManipulation);
this.textManipulation = textManipulation;
const destroyEvents = this.setupEvents();
@ -649,6 +664,40 @@ export default class DEditor extends Component {
};
}
@action
async toggleRichEditor() {
this.editorComponent = this.isRichEditorEnabled
? TextareaEditor
: await this.loadProsemirrorEditor();
scheduleOnce("afterRender", this, this.focus);
this.keyValueStore.set({
key: "d-editor-prefers-rich-editor",
value: this.isRichEditorEnabled,
});
}
async loadProsemirrorEditor() {
this.prosemirrorEditorClass ??= (
await import("discourse/static/prosemirror/components/prosemirror-editor")
).default;
return this.prosemirrorEditorClass;
}
focus() {
this.textManipulation.focus();
}
@action
onChange(event) {
this.set("value", event?.target?.value);
this.change?.(event);
}
get isRichEditorEnabled() {
return this.editorComponent === this.prosemirrorEditorClass;
}
setupEvents() {
const textManipulation = this.textManipulation;

View File

@ -0,0 +1,208 @@
const CUSTOM_NODE = {};
const CUSTOM_MARK = {};
const CUSTOM_PARSER = {};
const CUSTOM_NODE_SERIALIZER = {};
const CUSTOM_MARK_SERIALIZER = {};
const CUSTOM_NODE_VIEW = {};
const CUSTOM_INPUT_RULES = [];
const CUSTOM_PLUGINS = [];
const MULTIPLE_ALLOWED = { span: true, wrap_bbcode: false, bbcode: true };
/**
* @typedef {import('prosemirror-state').PluginSpec} PluginSpec
* @typedef {((pluginClass: typeof import('prosemirror-state').Plugin) => PluginSpec)} RichPluginFn
* @typedef {PluginSpec | RichPluginFn} RichPlugin
*
* @typedef {Object} RichEditorExtension
* @property {Object<string, import('prosemirror-model').NodeSpec>} [nodeSpec]
* Map containing Prosemirror node spec definitions, each key being the node name
* See https://prosemirror.net/docs/ref/#model.NodeSpec
* @property {Object<string, import('prosemirror-model').MarkSpec>} [markSpec]
* Map containing Prosemirror mark spec definitions, each key being the mark name
* See https://prosemirror.net/docs/ref/#model.MarkSpec
* @property {Array<typeof import("prosemirror-inputrules").InputRule>} [inputRules]
* Prosemirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule
* can be a function returning an array or an array of input rules
* @property {Object<string, import('prosemirror-markdown').NodeSerializerSpec>} [serializeNode]
* Node serialization definition
* @property {Object<string, import('prosemirror-markdown').MarkSerializerSpec>} [serializeMark]
* Mark serialization definition
* @property {Object<string, import('prosemirror-markdown').ParseSpec>} [parse]
* Markdown-it token parse definition
* @property {Array<RichPlugin>} [plugins]
* ProseMirror plugins
* @property {Object<string, import('prosemirror-view').NodeView>} [nodeViews]
*/
/**
* Register an extension for the rich editor
*
* @param {RichEditorExtension} extension
*/
export function registerRichEditorExtension(extension) {
if (extension.nodeSpec) {
Object.entries(extension.nodeSpec).forEach(([name, spec]) => {
addNode(name, spec);
});
}
if (extension.markSpec) {
Object.entries(extension.markSpec).forEach(([name, spec]) => {
addMark(name, spec);
});
}
if (extension.inputRules) {
addInputRule(extension.inputRules);
}
if (extension.serializeNode) {
Object.entries(extension.serializeNode).forEach(([name, serialize]) => {
addNodeSerializer(name, serialize);
});
}
if (extension.serializeMark) {
Object.entries(extension.serializeMark).forEach(([name, serialize]) => {
addMarkSerializer(name, serialize);
});
}
if (extension.parse) {
Object.entries(extension.parse).forEach(([name, parse]) => {
addParser(name, parse);
});
}
if (extension.plugins instanceof Array) {
extension.plugins.forEach(addPlugin);
} else if (extension.plugins) {
addPlugin(extension.plugins);
}
if (extension.nodeViews) {
Object.entries(extension.nodeViews).forEach(([name, nodeViews]) => {
addNodeView(name, nodeViews);
});
}
}
function addNode(type, spec) {
CUSTOM_NODE[type] = spec;
}
export function getNodes() {
return CUSTOM_NODE;
}
function addMark(type, spec) {
CUSTOM_MARK[type] = spec;
}
export function getMarks() {
return CUSTOM_MARK;
}
function addNodeView(type, NodeViewClass) {
CUSTOM_NODE_VIEW[type] = (node, view, getPos) =>
new NodeViewClass(node, view, getPos);
}
export function getNodeViews() {
return CUSTOM_NODE_VIEW;
}
function addInputRule(rule) {
CUSTOM_INPUT_RULES.push(rule);
}
export function getInputRules() {
return CUSTOM_INPUT_RULES;
}
function addPlugin(plugin) {
CUSTOM_PLUGINS.push(plugin);
}
export function getPlugins() {
return CUSTOM_PLUGINS;
}
function generateMultipleParser(tokenName, list, isOpenClose) {
if (isOpenClose) {
return {
[`${tokenName}_open`](state, token, tokens, i) {
if (!list) {
return;
}
for (let parser of list) {
if (parser(state, token, tokens, i)) {
return;
}
}
// No support for nested missing definitions
state[`skip${tokenName}Close`] ??= [];
},
[`${tokenName}_close`](state) {
if (!list) {
return;
}
if (state[`skip${tokenName}Close`]) {
state[`skip${tokenName}Close`] = false;
return;
}
state.closeNode();
},
};
} else {
return {
[tokenName](state, token, tokens, i) {
if (!list) {
return;
}
for (let parser of list) {
if (parser(state, token, tokens, i)) {
return;
}
}
},
};
}
}
function addParser(token, parse) {
if (MULTIPLE_ALLOWED[token] !== undefined) {
CUSTOM_PARSER[token] ??= [];
CUSTOM_PARSER[token].push(parse);
return;
}
CUSTOM_PARSER[token] = parse;
}
export function getParsers() {
const parsers = { ...CUSTOM_PARSER };
for (let [token, isOpenClose] of Object.entries(MULTIPLE_ALLOWED)) {
delete parsers[token];
Object.assign(
parsers,
generateMultipleParser(token, CUSTOM_PARSER[token], isOpenClose)
);
}
return parsers;
}
function addNodeSerializer(node, serialize) {
CUSTOM_NODE_SERIALIZER[node] = serialize;
}
export function getNodeSerializers() {
return CUSTOM_NODE_SERIALIZER;
}
function addMarkSerializer(mark, serialize) {
CUSTOM_MARK_SERIALIZER[mark] = serialize;
}
export function getMarkSerializers() {
return CUSTOM_MARK_SERIALIZER;
}

View File

@ -3,8 +3,9 @@
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "1.39.2";
export const PLUGIN_API_VERSION = "1.40.0";
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import $ from "jquery";
import { h } from "virtual-dom";
import { addAboutPageActivity } from "discourse/components/about-page";
@ -3407,6 +3408,10 @@ class PluginApi {
registeredTabs.push(tab);
}
registerRichEditorExtension(extension) {
registerRichEditorExtension(extension);
}
#deprecatedWidgetOverride(widgetName, override) {
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:
// if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) {

View File

@ -56,7 +56,7 @@ export async function parseMentions(markdown, options) {
return await withEngine("parseMentions", markdown, options);
}
function emojiOptions() {
export function emojiOptions() {
let siteSettings = helperContext().siteSettings;
let context = helperContext();
if (!siteSettings.enable_emoji) {

View File

@ -35,7 +35,7 @@ const FOUR_SPACES_INDENT = "4-spaces-indent";
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
export function getHead(head, prev) {
function getHead(head, prev) {
if (typeof head === "string") {
return [head, head.length];
} else {
@ -76,6 +76,10 @@ export default class TextareaTextManipulation {
return this.textarea.value;
}
get allowPreview() {
return true;
}
// ensures textarea scroll position is correct
blurAndFocus() {
this.textarea?.blur();

View File

@ -0,0 +1,147 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import "../extensions";
import {
getNodeViews,
getPlugins,
} from "discourse/lib/composer/rich-editor-extensions";
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 { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { bind } from "discourse-common/utils/decorators";
import { convertFromMarkdown } from "../lib/parser";
import TextManipulation from "../lib/text-manipulation";
import { createSchema } from "../lib/schema";
import { convertToMarkdown } from "../lib/serializer";
import { buildInputRules } from "../plugins/inputrules";
import { buildKeymap } from "../plugins/keymap";
import placeholder from "../plugins/placeholder";
export default class ProsemirrorEditor extends Component {
@service appEvents;
@service menu;
@service siteSettings;
@tracked rootElement;
editorContainerId = guidFor(this);
schema = createSchema();
view;
state;
plugins = this.args.plugins;
@action
async setup() {
this.rootElement = document.getElementById(this.editorContainerId);
const keymapFromArgs = Object.entries(this.args.keymap).reduce(
(acc, [key, value]) => {
// original keymap uses itsatrap format
acc[key.replaceAll("+", "-")] = value;
return acc;
},
{}
);
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)
),
];
this.state = EditorState.create({
schema: this.schema,
plugins: this.plugins,
});
this.view = new EditorView(this.rootElement, {
nodeViews: this.args.nodeViews ?? getNodeViews(),
state: this.state,
attributes: { class: "d-editor-input d-editor__editable" },
dispatchTransaction: (tr) => {
this.view.updateState(this.view.state.apply(tr));
if (tr.docChanged && tr.getMeta("addToHistory") !== false) {
// TODO(renato): avoid calling this on every change
const value = convertToMarkdown(this.view.state.doc);
this.args.change?.({ target: { value } });
}
},
handleDOMEvents: {
focus: () => {
this.args.focusIn?.();
return false;
},
blur: () => {
this.args.focusOut?.();
return false;
},
},
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
return (
event.key === "Enter" && !!document.querySelector(".autocomplete")
);
},
});
this.textManipulation = new TextManipulation(getOwner(this), {
markdownOptions: this.args.markdownOptions,
schema: this.schema,
view: this.view,
});
this.destructor = this.args.onSetup(this.textManipulation);
await this.convertFromValue();
}
@bind
async convertFromValue() {
const doc = await convertFromMarkdown(this.schema, this.args.value);
// doc.check();
// console.log("Resulting doc:", doc);
const tr = this.state.tr
.replaceWith(0, this.state.doc.content.size, doc.content)
.setMeta("addToHistory", false);
this.view.updateState(this.view.state.apply(tr));
}
@action
teardown() {
this.destructor?.();
}
<template>
<div
id={{this.editorContainerId}}
class="d-editor__container"
{{didInsert this.setup}}
{{willDestroy this.teardown}}
>
</div>
</template>
}

View File

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

View File

@ -0,0 +1,71 @@
import { buildEmojiUrl, emojiExists, isCustomEmoji } from "pretty-text/emoji";
import { emojiOptions } from "discourse/lib/text";
// TODO(renato): we need to avoid the invalid text:emoji: state (reminder to use isPunctChar to avoid deleting the space)
export default {
nodeSpec: {
emoji: {
attrs: { code: {} },
inline: true,
group: "inline",
draggable: true,
selectable: false,
parseDOM: [
{
tag: "img.emoji",
getAttrs: (dom) => {
return { code: dom.getAttribute("alt").replace(/:/g, "") };
},
},
],
toDOM: (node) => {
const opts = emojiOptions();
const code = node.attrs.code.toLowerCase();
const title = `:${code}:`;
const src = buildEmojiUrl(code, opts);
return [
"img",
{
class: isCustomEmoji(code, opts) ? "emoji emoji-custom" : "emoji",
alt: title,
title,
src,
},
];
},
leafText: (node) => `:${node.attrs.code}:`,
},
},
inputRules: [
{
match: /(?<=^|\W):([^:]+):$/,
handler: (state, match, start, end) => {
if (emojiExists(match[1])) {
return state.tr.replaceWith(
start,
end,
state.schema.nodes.emoji.create({ code: match[1] })
);
}
},
options: { undoable: false },
},
],
parse: {
emoji: {
node: "emoji",
getAttrs: (token) => ({
code: token.attrGet("alt").slice(1, -1),
}),
},
},
serializeNode: {
emoji: (state, node) => {
state.write(`:${node.attrs.code}:`);
},
},
};

View File

@ -0,0 +1,60 @@
export default {
nodeSpec: {
hashtag: {
attrs: { name: {} },
inline: true,
group: "inline",
content: "text*",
atom: true,
draggable: true,
selectable: false,
parseDOM: [
{
tag: "a.hashtag-cooked",
preserveWhitespace: "full",
getAttrs: (dom) => {
return { name: dom.getAttribute("data-name") };
},
},
],
toDOM: (node) => {
return [
"a",
{ class: "hashtag-cooked", "data-name": node.attrs.name },
`#${node.attrs.name}`,
];
},
leafText: (node) => `#${node.attrs.name}`,
},
},
inputRules: [
{
match: /#([\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, [
state.schema.nodes.hashtag.create({ name: match[1] }),
state.schema.text(" "),
]),
options: { undoable: false },
},
],
parse: {
span: (state, token, tokens, i) => {
if (token.attrGet("class") === "hashtag-raw") {
state.openNode(state.schema.nodes.hashtag, {
name: tokens[i + 1].content.slice(1),
});
return true;
}
},
},
serializeNode: {
hashtag: (state, node) => {
state.write(`#${node.attrs.name}`);
},
},
};

View File

@ -0,0 +1,22 @@
export default {
nodeSpec: {
heading: {
attrs: { level: { default: 1 } },
// Overriding ProseMirror's default to allow inline content
content: "inline*",
group: "block",
defining: true,
parseDOM: [
{ tag: "h1", attrs: { level: 1 } },
{ tag: "h2", attrs: { level: 2 } },
{ tag: "h3", attrs: { level: 3 } },
{ tag: "h4", attrs: { level: 4 } },
{ tag: "h5", attrs: { level: 5 } },
{ tag: "h6", attrs: { level: 6 } },
],
toDOM(node) {
return ["h" + node.attrs.level, 0];
},
},
},
};

View File

@ -0,0 +1,92 @@
const HTML_INLINE_MARKS = {
s: "strikethrough",
strike: "strikethrough",
strong: "strong",
b: "strong",
em: "em",
i: "em",
code: "code",
};
const ALLOWED_INLINE = [
"kbd",
"sup",
"sub",
"small",
"big",
"del",
"ins",
"mark",
];
export default {
nodeSpec: {
// TODO(renato): this node is hard to get past when at the end of a block
// and is added to a newline unintentionally, investigate
html_inline: {
group: "inline",
inline: true,
content: "inline*",
attrs: { tag: {} },
parseDOM: ALLOWED_INLINE.map((tag) => ({ tag })),
toDOM: (node) => [node.attrs.tag, 0],
},
},
parse: {
// TODO(renato): it breaks if it's missing an end tag
html_inline: (state, token) => {
const openMatch = token.content.match(/^<([a-z]+)>$/u);
const closeMatch = token.content.match(/^<\/([a-z]+)>$/u);
if (openMatch) {
const tagName = openMatch[1];
const markName = HTML_INLINE_MARKS[tagName];
if (markName) {
state.openMark(state.schema.marks[markName].create());
return;
}
if (ALLOWED_INLINE.includes(tagName)) {
state.openNode(state.schema.nodeType("html_inline"), {
tag: tagName,
});
}
return;
}
if (closeMatch) {
const tagName = closeMatch[1];
const markName = HTML_INLINE_MARKS[tagName];
if (markName) {
state.closeMark(state.schema.marks[markName].create());
return;
}
if (ALLOWED_INLINE.includes(tagName)) {
state.closeNode();
}
}
},
},
serializeNode: {
html_inline(state, node) {
state.write(`<${node.attrs.tag}>`);
state.renderInline(node);
state.write(`</${node.attrs.tag}>`);
},
},
inputRules: {
match: new RegExp(`<(${ALLOWED_INLINE.join("|")})>`),
handler: (state, match, start, end) => {
const tag = match[1];
// TODO not finished
state.tr.replaceWith(
start,
end,
state.schema.nodes.html_inline.create({ tag })
);
},
},
};

View File

@ -0,0 +1,170 @@
import {
lookupCachedUploadUrl,
lookupUncachedUploadUrls,
} from "pretty-text/upload-short-url";
import { ajax } from "discourse/lib/ajax";
import { isNumeric } from "discourse/lib/utilities";
const PLACEHOLDER_IMG = "/images/transparent.png";
const ALT_TEXT_REGEX =
/^(.*?)(?:\|(\d{1,4}x\d{1,4}))?(?:,\s*(\d{1,3})%)?(?:\|(.*))?$/;
export default {
nodeSpec: {
image: {
inline: true,
attrs: {
src: {},
alt: { default: null },
title: { default: null },
// Overriding ProseMirror's default node to support these attrs
width: { default: null },
height: { default: null },
"data-orig-src": { default: null },
"data-thumbnail": { default: false },
"data-scale": { default: null },
"data-placeholder": { default: null },
},
group: "inline",
draggable: true,
parseDOM: [
{
tag: "img[src]",
getAttrs(dom) {
return {
src: dom.getAttribute("src"),
title: dom.getAttribute("title"),
alt: dom.getAttribute("alt"),
width: dom.getAttribute("width"),
height: dom.getAttribute("height"),
"data-orig-src": dom.getAttribute("data-orig-src"),
"data-thumbnail": dom.hasAttribute("data-thumbnail"),
};
},
},
],
toDOM(node) {
const width = node.attrs.width
? (node.attrs.width * (node.attrs["data-scale"] || 100)) / 100
: undefined;
const height = node.attrs.height
? (node.attrs.height * (node.attrs["data-scale"] || 100)) / 100
: undefined;
return ["img", { ...node.attrs, width, height }];
},
},
},
parse: {
image: {
node: "image",
getAttrs(token) {
const [, altText, dimensions, percent, extras] =
token.content.match(ALT_TEXT_REGEX);
const [width, height] = dimensions?.split("x") ?? [];
return {
src: token.attrGet("src"),
title: token.attrGet("title"),
alt: altText,
"data-orig-src": token.attrGet("data-orig-src"),
width,
height,
"data-scale":
percent && isNumeric(percent) ? parseInt(percent, 10) : undefined,
"data-thumbnail": extras === "thumbnail",
};
},
},
},
serializeNode: {
image(state, node) {
if (node.attrs["data-placeholder"]) {
return;
}
const alt = (node.attrs.alt || "").replace(/([\\[\]`])/g, "\\$1");
const scale = node.attrs["data-scale"]
? `, ${node.attrs["data-scale"]}%`
: "";
const dimensions =
node.attrs.width && node.attrs.height
? `|${node.attrs.width}x${node.attrs.height}${scale}`
: "";
const thumbnail = node.attrs["data-thumbnail"] ? "|thumbnail" : "";
const src = node.attrs["data-orig-src"] ?? node.attrs.src ?? "";
const escapedSrc = src.replace(/[\(\)]/g, "\\$&");
const title = node.attrs.title
? ' "' + node.attrs.title.replace(/"/g, '\\"') + '"'
: "";
state.write(`![${alt}${dimensions}${thumbnail}](${escapedSrc}${title})`);
},
},
plugins: (Plugin) => {
const shortUrlResolver = new Plugin({
state: {
init() {
return [];
},
apply(tr, value) {
let updated = value.slice();
tr.doc.descendants((node, pos) => {
if (node.type.name === "image" && node.attrs["data-orig-src"]) {
if (node.attrs.src === PLACEHOLDER_IMG) {
updated.push({ pos, src: node.attrs["data-orig-src"] });
} else {
updated = updated.filter(
(u) => u.src !== node.attrs["data-orig-src"]
);
}
}
});
return updated;
},
},
view() {
return {
update: async (view, prevState) => {
if (prevState.doc.eq(view.state.doc)) {
return;
}
const unresolvedUrls = shortUrlResolver.getState(view.state);
// Process only unresolved URLs
for (const unresolved of unresolvedUrls) {
const cachedUrl = lookupCachedUploadUrl(unresolved.src).url;
const url =
cachedUrl ||
(await lookupUncachedUploadUrls([unresolved.src], ajax))[0]
?.url;
if (url) {
const node = view.state.doc.nodeAt(unresolved.pos);
if (node) {
const attrs = { ...node.attrs, src: url };
const transaction = view.state.tr
.setNodeMarkup(unresolved.pos, null, attrs)
.setMeta("addToHistory", false);
view.dispatch(transaction);
}
}
}
},
};
},
});
return shortUrlResolver;
},
};

View File

@ -0,0 +1,35 @@
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 typographerReplacements from "./typographer-replacements";
import underlineExtension from "./underline";
const defaultExtensions = [
emojiExtension,
// image must be after emoji
imageExtension,
hashtagExtension,
mentionExtension,
strikethroughExtension,
underlineExtension,
htmlInlineExtension,
linkExtension,
headingExtension,
typographerReplacements,
codeLangSelector,
quoteExtension,
// table must be last
tableExtension,
];
defaultExtensions.forEach(registerRichEditorExtension);

View File

@ -0,0 +1,32 @@
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;
}
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);
},
},
],
};

View File

@ -0,0 +1,65 @@
// TODO(renato): similar to emoji, avoid joining anything@mentions, as it's invalid markdown
import { mentionRegex } from "pretty-text/mentions";
export default {
nodeSpec: {
mention: {
attrs: { name: {} },
inline: true,
group: "inline",
content: "text*",
atom: true,
draggable: true,
selectable: false,
parseDOM: [
{
tag: "a.mention",
preserveWhitespace: "full",
getAttrs: (dom) => {
return { name: dom.getAttribute("data-name") };
},
},
],
toDOM: (node) => {
return [
"a",
{ class: "mention", "data-name": node.attrs.name },
`@${node.attrs.name}`,
];
},
leafText: (node) => `@${node.attrs.name}`,
},
},
inputRules: [
{
// TODO: pass unicodeUsernames?
match: new RegExp(`(${mentionRegex().source}) $`),
handler: (state, match, start, end) =>
state.selection.$from.nodeBefore?.type !== state.schema.nodes.mention &&
state.tr.replaceWith(start, end, [
state.schema.nodes.mention.create({ name: match[1].slice(1) }),
state.schema.text(" "),
]),
options: { undoable: false },
},
],
parse: {
mention: {
block: "mention",
getAttrs: (token, tokens, i) => ({
// this is not ideal, but working around the mention_open/close structure
// a text is expected just after the mention_open token
name: tokens[i + 1].content.slice(1),
}),
},
},
serializeNode: {
mention: (state, node) => {
state.write(`@${node.attrs.name}`);
},
},
};

View File

@ -0,0 +1,90 @@
export default {
nodeSpec: {
quote: {
content: "block+",
group: "block",
defining: true,
inline: false,
attrs: {
username: {},
postNumber: { default: null },
topicId: { default: null },
full: { default: null },
},
parseDOM: [
{
tag: "aside.quote",
getAttrs(dom) {
return {
username: dom.getAttribute("data-username"),
postNumber: dom.getAttribute("data-post"),
topicId: dom.getAttribute("data-topic"),
full: dom.getAttribute("data-full"),
};
},
},
],
toDOM(node) {
const { username, postNumber, topicId, full } = node.attrs;
const attrs = { class: "quote" };
attrs["data-username"] = username;
attrs["data-post"] = postNumber;
attrs["data-topic"] = topicId;
attrs["data-full"] = full ? "true" : "false";
return ["aside", attrs, 0];
},
},
quote_title: {
content: "inline*",
group: "block",
inline: false,
parseDOM: [{ tag: "aside[data-username] > div.title" }],
atom: true,
draggable: false,
selectable: false,
toDOM() {
return ["div", { class: "title" }, 0];
},
},
},
parse: {
quote_header: { block: "quote_title" },
quote_controls: { ignore: true },
bbcode(state, token) {
if (token.tag === "aside") {
state.openNode(state.schema.nodes.quote, {
username: token.attrGet("data-username"),
postNumber: token.attrGet("data-post"),
topicId: token.attrGet("data-topic"),
full: token.attrGet("data-full"),
});
return true;
}
if (token.tag === "blockquote") {
state.openNode(state.schema.nodes.blockquote);
return true;
}
},
},
serializeNode: {
quote(state, node) {
const postNumber = node.attrs.postNumber
? `, post:${node.attrs.postNumber}`
: "";
const topicId = node.attrs.topicId ? `, topic:${node.attrs.topicId}` : "";
state.write(`[quote="${node.attrs.username}${postNumber}${topicId}"]\n`);
node.forEach((n) => {
if (n.type.name === "blockquote") {
state.renderContent(n);
}
});
state.write("[/quote]\n");
},
quote_title() {},
},
};

View File

@ -0,0 +1,30 @@
export default {
markSpec: {
strikethrough: {
parseDOM: [
{ tag: "s" },
{ tag: "del" },
{
getAttrs: (value) =>
/(^|[\s])line-through([\s]|$)/u.test(value) && null,
style: "text-decoration",
},
],
toDOM() {
return ["s"];
},
},
},
parse: {
s: { mark: "strikethrough" },
bbcode_s: { mark: "strikethrough" },
},
serializeMark: {
strikethrough: {
open: "~~",
close: "~~",
mixable: true,
expelEnclosingWhitespace: true,
},
},
};

View File

@ -0,0 +1,176 @@
// It makes sense to use prosemirror-tables for some of the table functionality,
// but it has some differences from our structure (e.g. no thead/tbody).
//
// The main missing part of this extension for now is a UI companion
// Example:
//
// | Left-aligned | Center-aligned | Right-aligned |
// | :--- | :---: | ---: |
// | git status | git status | git status |
// | git diff | git diff | git diff |
export default {
nodeSpec: {
table: {
content: "(table_head | table_body)+",
group: "block",
tableRole: "table",
isolating: true,
parseDOM: [{ tag: "table" }],
toDOM() {
return ["table", 0];
},
},
table_head: {
content: "table_row",
tableRole: "head",
isolating: true,
parseDOM: [{ tag: "thead" }],
toDOM() {
return ["thead", 0];
},
},
table_body: {
content: "table_row*",
tableRole: "body",
isolating: true,
parseDOM: [{ tag: "tbody" }],
toDOM() {
return ["tbody", 0];
},
},
table_row: {
content: "(table_header_cell | table_cell)*",
tableRole: "row",
parseDOM: [{ tag: "tr" }],
toDOM() {
return ["tr", 0];
},
},
table_header_cell: {
content: "inline*",
tableRole: "header_cell",
attrs: { alignment: { default: null } },
parseDOM: [
{
tag: "th",
getAttrs(dom) {
return { alignment: dom.style.textAlign };
},
},
],
toDOM(node) {
return ["th", { style: `text-align: ${node.attrs.alignment}` }, 0];
},
},
table_cell: {
content: "inline*",
tableRole: "cell",
attrs: { alignment: { default: null } },
parseDOM: [
{
tag: "td",
getAttrs(dom) {
return { alignment: dom.style.textAlign };
},
},
],
toDOM(node) {
return [
"td",
{
style: node.attrs.alignment
? `text-align: ${node.attrs.alignment}`
: undefined,
},
0,
];
},
},
},
parse: {
table: { block: "table" },
thead: { block: "table_head" },
tbody: { block: "table_body" },
tr: { block: "table_row" },
th: {
block: "table_header_cell",
getAttrs(token) {
return {
alignment: token.attrGet("style")?.match(/text-align:(\w+)/)?.[1],
};
},
},
td: {
block: "table_cell",
getAttrs(token) {
return {
alignment: token.attrGet("style")?.match(/text-align:(\w+)/)?.[1],
};
},
},
},
serializeNode: {
// TODO(renato): state.renderInline should escape `|` if `state.inTable`
table(state, node) {
state.flushClose(1);
let headerBuffer = state.delim && state.atBlank() ? state.delim : "";
const prevInTable = state.inTable;
state.inTable = true;
// group is table_head or table_body
node.forEach((group, groupOffset, groupIndex) => {
group.forEach((row) => {
row.forEach((cell, cellOffset, cellIndex) => {
if (state.delim && state.atBlank()) {
state.out += state.delim;
}
state.out += cellIndex === 0 ? "| " : " | ";
cell.forEach((cellNode) => {
if (
cellNode.textContent === "" &&
cellNode.content.size === 0 &&
cellNode.type.name === "paragraph"
) {
state.out += " ";
} else {
state.closed = false;
state.render(cellNode, row, cellIndex);
}
});
// if table_head
if (groupIndex === 0) {
if (cell.attrs.alignment === "center") {
headerBuffer += "|:---:";
} else if (cell.attrs.alignment === "left") {
headerBuffer += "|:---";
} else if (cell.attrs.alignment === "right") {
headerBuffer += "|---:";
} else {
headerBuffer += "|----";
}
}
});
state.out += " |\n";
if (headerBuffer) {
state.out += `${headerBuffer}|\n`;
headerBuffer = undefined;
}
});
});
state.out += "\n";
state.inTable = prevInTable;
},
table_head() {},
table_body() {},
table_row() {},
table_header_cell() {},
table_cell() {},
},
};

View File

@ -0,0 +1,33 @@
import {
RARE_RE,
replaceRareStr,
replaceScopedStr,
SCOPED_ABBR_RE,
} from "discourse-markdown-it/features/custom-typographer-replacements";
// TODO(renato): should respect `enable_markdown_typographer`
export default {
inputRules: [
{
match: new RegExp(`(${RARE_RE.source})$`),
handler: (state, match, start, end) => {
return state.tr.replaceWith(
start,
end,
state.schema.text(replaceRareStr(match[0]).trim())
);
},
},
{
match: new RegExp(`(${SCOPED_ABBR_RE.source})$`, "i"),
handler: (state, match, start, end) => {
return state.tr.replaceWith(
start,
end,
state.schema.text(replaceScopedStr(match[0]))
);
},
},
],
};

View File

@ -0,0 +1,21 @@
export default {
markSpec: {
underline: {
toDOM() {
return ["u", 0];
},
parseDOM: [{ tag: "u" }],
},
},
parse: {
bbcode_u: { mark: "underline" },
},
serializeMark: {
underline: {
open: "[u]",
close: "[/u]",
mixable: true,
expelEnclosingWhitespace: true,
},
},
};

View File

@ -0,0 +1,52 @@
import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown";
import { getParsers } from "discourse/lib/composer/rich-editor-extensions";
import { parseAsync } from "discourse/lib/text";
// 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
const postParseTokens = {
softbreak: (state) => state.addText("\n"),
...parseFunctions,
};
export async function convertFromMarkdown(schema, text) {
const tokens = await parseAsync(text);
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;
}
return parser.parse(text);
}

View File

@ -0,0 +1,21 @@
import { schema as defaultMarkdownSchema } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
import {
getMarks,
getNodes,
} from "discourse/lib/composer/rich-editor-extensions";
export function createSchema() {
let nodes = defaultMarkdownSchema.spec.nodes;
let marks = defaultMarkdownSchema.spec.marks;
for (const [type, spec] of Object.entries(getNodes())) {
nodes = nodes.addToEnd(type, spec);
}
for (const [type, spec] of Object.entries(getMarks())) {
marks = marks.addToEnd(type, spec);
}
return new Schema({ nodes, marks });
}

View File

@ -0,0 +1,31 @@
import {
defaultMarkdownSerializer,
MarkdownSerializer,
} from "prosemirror-markdown";
import {
getMarkSerializers,
getNodeSerializers,
} from "discourse/lib/composer/rich-editor-extensions";
const serializeNodes = {
...defaultMarkdownSerializer.nodes,
// Custom
hard_break(state) {
state.write("\n");
},
...getNodeSerializers(),
};
const serializeMarks = {
...defaultMarkdownSerializer.marks,
// Custom
...getMarkSerializers(),
};
export function convertToMarkdown(doc) {
// console.log("Doc to serialize", doc);
return new MarkdownSerializer(serializeNodes, serializeMarks).serialize(doc);
}

View File

@ -0,0 +1,446 @@
import { setOwner } from "@ember/owner";
import $ from "jquery";
import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
import { convertFromMarkdown } from "discourse/static/prosemirror/lib/parser";
import { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
export default class TextManipulation {
markdownOptions;
/** @type {import("prosemirror-model").Schema} */
schema;
/** @type {import("prosemirror-view").EditorView} */
view;
$editorElement;
placeholder;
autocompleteHandler;
constructor(owner, { markdownOptions, schema, view }) {
setOwner(this, owner);
this.markdownOptions = markdownOptions;
this.schema = schema;
this.view = view;
this.$editorElement = $(view.dom);
this.placeholder = new PlaceholderHandler({ schema, view });
this.autocompleteHandler = new AutocompleteHandler({ schema, view });
}
/**
* The textual value of the selected text block
* @returns {string}
*/
get value() {
const parent = this.view.state.selection.$head.parent;
return parent.textBetween(0, parent.nodeSize - 2, " ", " ");
}
getSelected(trimLeading, opts) {
const start = this.view.state.selection.from;
const end = this.view.state.selection.to;
const value = this.view.state.doc.textBetween(start, end, " ", " ");
return {
start,
end,
pre: "",
value,
post: "",
};
}
focus() {
this.view.focus();
}
blurAndFocus() {
this.focus();
}
putCursorAtEnd() {
// this.view.dispatch(
// this.view.state.tr.setSelection(
// TextSelection.create(this.view.state.doc, 0)
// )
// );
}
autocomplete(options) {
return this.$editorElement.autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
}
applySurroundSelection(head, tail, exampleKey, opts) {
this.applySurround(this.getSelected(), head, tail, exampleKey, opts);
}
applySurround(sel, head, tail, exampleKey, opts) {
const applySurroundMap = {
italic_text: this.schema.marks.em,
bold_text: this.schema.marks.strong,
code_title: this.schema.marks.code,
};
if (applySurroundMap[exampleKey]) {
toggleMark(applySurroundMap[exampleKey])(
this.view.state,
this.view.dispatch
);
return;
}
// TODO other cases, probably through md parser
}
async addText(sel, text, options) {
const doc = await convertFromMarkdown(
this.schema,
text,
this.markdownOptions
);
// assumes it returns a single block node
const content = doc.content.firstChild.content;
this.view.dispatch(
this.view.state.tr.replaceWith(sel.start, sel.end, content)
);
}
async insertBlock(block) {
const doc = await convertFromMarkdown(this.schema, block);
this.view.dispatch(
this.view.state.tr.replaceWith(
this.view.state.selection.from - 1,
this.view.state.selection.to,
doc.content.firstChild
)
);
}
applyList(_selection, head, exampleKey, opts) {
// This is similar to applySurround, but doing it line by line
// We may use markdown parsing as a fallback if we don't identify the exampleKey
// similarly to applySurround
// TODO to check actual applyList uses in the wild
let command;
const isInside = (type) => {
const $from = this.view.state.selection.$from;
for (let depth = $from.depth; depth > 0; depth--) {
const parent = $from.node(depth);
if (parent.type === type) {
return true;
}
}
return false;
};
if (exampleKey === "list_item") {
if (head === "* ") {
command = isInside(this.schema.nodes.bullet_list)
? lift
: wrapIn(this.schema.nodes.bullet_list);
} else {
command = isInside(this.schema.nodes.ordered_list)
? lift
: wrapIn(this.schema.nodes.ordered_list);
}
} else {
const applyListMap = {
blockquote_text: this.schema.nodes.blockquote,
};
if (applyListMap[exampleKey]) {
command = isInside(applyListMap[exampleKey])
? lift
: wrapIn(applyListMap[exampleKey]);
}
}
command?.(this.view.state, this.view.dispatch);
}
formatCode() {
let command;
const selection = this.view.state.selection;
if (selection.$from.parent.type === this.schema.nodes.code_block) {
command = setBlockType(this.schema.nodes.paragraph);
} else if (
selection.$from.pos !== selection.$to.pos &&
selection.$from.parent === selection.$to.parent
) {
command = toggleMark(this.schema.marks.code);
} else {
command = setBlockType(this.schema.nodes.code_block);
}
command?.(this.view.state, this.view.dispatch);
}
@bind
emojiSelected(code) {
const text = this.value.slice(0, this.getCaretPosition());
const captures = text.match(/\B:(\w*)$/);
if (!captures) {
if (text.match(/\S$/)) {
this.view.dispatch(
this.view.state.tr
.insertText(" ", this.view.state.selection.from)
.replaceSelectionWith(this.schema.nodes.emoji.create({ code }))
);
} else {
this.view.dispatch(
this.view.state.tr.replaceSelectionWith(
this.schema.nodes.emoji.create({ code })
)
);
}
} else {
let numOfRemovedChars = captures[1].length;
this.view.dispatch(
this.view.state.tr
.delete(
this.view.state.selection.from - numOfRemovedChars - 1,
this.view.state.selection.from
)
.replaceSelectionWith(this.schema.nodes.emoji.create({ code }))
);
}
this.focus();
}
@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();
}
selectText() {
// TODO
}
@bind
inCodeBlock() {
return (
this.view.state.selection.$from.parent.type ===
this.schema.nodes.code_block
);
}
/**
* Gets the textual caret position within the selected text block
*
* @returns {number}
*/
getCaretPosition() {
const { $anchor } = this.view.state.selection;
return $anchor.pos - $anchor.start();
}
}
class AutocompleteHandler {
/** @type {import("prosemirror-view").EditorView} */
view;
/** @type {import("prosemirror-model").Schema} */
schema;
constructor({ schema, view }) {
this.schema = schema;
this.view = view;
}
/**
* The textual value of the selected text block
* @returns {string}
*/
get value() {
return this.view.state.selection.$head.nodeBefore?.textContent ?? "";
}
/**
* Replaces the term between start-end in the currently selected text block
*
* It uses input rules to convert it to a node if possible
*
* @param {number} start
* @param {number} end
* @param {String} term
*/
async 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;
// Alternative approach using inputRules, if `convertFromMarkdown` is too expensive
//
// let replaced;
// for (const plugin of this.view.state.plugins) {
// if (plugin.spec.isInputRules) {
// replaced ||= plugin.props.handleTextInput(this.view, from, to, term, null);
// }
// }
//
// if (!replaced) {
// this.view.dispatch(
// this.view.state.tr.replaceWith(from, to, this.schema.text(term))
// );
// }
const doc = await convertFromMarkdown(this.schema, term);
const tr = this.view.state.tr.replaceWith(
from,
to,
doc.content.firstChild.content
);
tr.insertText(" ", tr.selection.from);
this.view.dispatch(tr);
}
/**
* Gets the textual caret position within the selected text block
*
* @returns {number}
*/
getCaretPosition() {
const node = this.view.state.selection.$head.nodeBefore;
if (!node?.isText) {
return 0;
}
return node.nodeSize;
}
/**
* Gets the caret coordinates within the selected text block
*
* @param {number} start
*
* @returns {{top: number, left: number}}
*/
getCaretCoords(start) {
const node = this.view.state.selection.$head.nodeBefore;
const pos = this.view.state.selection.from - node.nodeSize + start;
const { left, top } = this.view.coordsAtPos(pos);
const rootRect = this.view.dom.getBoundingClientRect();
return {
left: left - rootRect.left,
top: top - rootRect.top,
};
}
inCodeBlock() {
// TODO
return false;
}
}
class PlaceholderHandler {
view;
schema;
constructor({ schema, view }) {
this.schema = schema;
this.view = view;
}
insert(file) {
const isEmptyParagraph =
this.view.state.selection.$from.parent.type.name === "paragraph" &&
this.view.state.selection.$from.parent.nodeSize === 2;
const imageNode = this.schema.nodes.image.create({
src: URL.createObjectURL(file.data),
alt: i18n("uploading_filename", { filename: file.name }),
title: file.id,
width: 120,
"data-placeholder": true,
});
this.view.dispatch(
this.view.state.tr.insert(
this.view.state.selection.from,
isEmptyParagraph
? imageNode
: this.schema.nodes.paragraph.create(null, imageNode)
)
);
}
progress() {}
progressComplete() {}
cancelAll() {
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"]
) {
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
}
});
}
cancel(file) {
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"] &&
node.attrs?.title === file.id
) {
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
}
});
}
async success(file, markdown) {
let nodeToReplace = null;
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"] &&
node.attrs?.title === file.id
) {
nodeToReplace = { node, pos };
return false;
}
return true;
});
// keeping compatibility with plugins that change the image node via markdown
const doc = await convertFromMarkdown(this.schema, markdown);
this.view.dispatch(
this.view.state.tr.replaceWith(
nodeToReplace.pos,
nodeToReplace.pos + nodeToReplace.node.nodeSize,
doc.content.firstChild.content
)
);
}
}

View File

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

View File

@ -0,0 +1,136 @@
import {
InputRule,
inputRules,
smartQuotes,
textblockTypeInputRule,
wrappingInputRule,
} from "prosemirror-inputrules";
import { getInputRules } from "discourse/lib/composer/rich-editor-extensions";
function orderedListRule(nodeType) {
return wrappingInputRule(
/^(\d+)\.\s$/,
nodeType,
(match) => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1]
);
}
function bulletListRule(nodeType) {
return wrappingInputRule(/^\s*([-+*])\s$/, nodeType);
}
function headingRule(nodeType, maxLevel) {
return textblockTypeInputRule(
new RegExp("^(#{1," + maxLevel + "})\\s$"),
nodeType,
(match) => ({ level: match[1].length })
);
}
// https://discuss.prosemirror.net/t/input-rules-for-wrapping-marks/537
function markInputRule(regexp, markType, getAttrs) {
return new InputRule(regexp, (state, match, start, end) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
const tr = state.tr;
if (match[1]) {
let textStart = start + match[0].indexOf(match[1]);
let textEnd = textStart + match[1].length;
if (textEnd < end) {
tr.delete(textEnd, end);
}
if (textStart > start) {
tr.delete(start, textStart);
}
end = start + match[1].length;
}
tr.addMark(start, end, markType.create(attrs));
tr.removeStoredMark(markType);
return tr;
});
}
export function buildInputRules(schema) {
// TODO(renato) smartQuotes should respect `markdown_typographer_quotation_marks`
let rules = [...smartQuotes],
type;
if ((type = schema.nodes.blockquote)) {
rules.push(wrappingInputRule(/^\s*>\s$/, type));
}
if ((type = schema.nodes.ordered_list)) {
rules.push(orderedListRule(type));
}
if ((type = schema.nodes.bullet_list)) {
rules.push(bulletListRule(type));
}
if ((type = schema.nodes.code_block)) {
rules.push(textblockTypeInputRule(/^```$/, type));
rules.push(textblockTypeInputRule(/^ {4}$/, type));
}
if ((type = schema.nodes.heading)) {
rules.push(headingRule(type, 6));
}
const marks = schema.marks;
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
.concat(markInputRules)
.concat(
getInputRules().flatMap((inputRule) =>
processInputRule(inputRule, schema)
)
);
return inputRules({ rules });
}
function processInputRule(inputRule, schema) {
if (inputRule instanceof Array) {
return inputRule.map(processInputRule);
}
if (inputRule instanceof Function) {
inputRule = inputRule({ schema, markInputRule });
}
if (inputRule instanceof InputRule) {
return inputRule;
}
if (
inputRule.match instanceof RegExp &&
inputRule.handler instanceof Function
) {
return new InputRule(inputRule.match, inputRule.handler, inputRule.options);
}
throw new Error("Input rule must have a match regex and a handler function");
}

View File

@ -0,0 +1,101 @@
import {
chainCommands,
exitCode,
joinDown,
joinUp,
lift,
selectParentNode,
setBlockType,
toggleMark,
wrapIn,
} from "prosemirror-commands";
import { redo, undo } from "prosemirror-history";
import { undoInputRule } from "prosemirror-inputrules";
import {
liftListItem,
sinkListItem,
splitListItem,
} from "prosemirror-schema-list";
const isMac =
typeof navigator !== "undefined"
? /Mac|iP(hone|[oa]d)/.test(navigator.platform)
: false;
// Updated from
// https://github.com/ProseMirror/prosemirror-example-setup/blob/master/src/keymap.ts
export function buildKeymap(schema, initialKeymap = {}, suppressKeys) {
let keys = initialKeymap,
type;
function bind(key, cmd) {
if (suppressKeys) {
let mapped = suppressKeys[key];
if (mapped === false) {
return;
}
if (mapped) {
key = mapped;
}
}
keys[key] = cmd;
}
bind("Mod-z", undo);
bind("Shift-Mod-z", redo);
bind("Backspace", undoInputRule);
if (!isMac) {
bind("Mod-y", redo);
}
bind("Alt-ArrowUp", joinUp);
bind("Alt-ArrowDown", joinDown);
bind("Mod-BracketLeft", lift);
bind("Escape", selectParentNode);
if ((type = schema.marks.code)) {
bind("Mod-`", toggleMark(type));
}
if ((type = schema.nodes.blockquote)) {
bind("Ctrl->", wrapIn(type));
}
if ((type = schema.nodes.hard_break)) {
let br = type,
cmd = chainCommands(exitCode, (state, dispatch) => {
if (dispatch) {
dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView());
}
return true;
});
bind("Mod-Enter", cmd);
bind("Shift-Enter", cmd);
if (isMac) {
bind("Ctrl-Enter", cmd);
}
}
if ((type = schema.nodes.list_item)) {
bind("Enter", splitListItem(type));
bind("Mod-[", liftListItem(type));
bind("Mod-]", sinkListItem(type));
}
if ((type = schema.nodes.paragraph)) {
bind("Shift-Ctrl-0", setBlockType(type));
}
if ((type = schema.nodes.code_block)) {
bind("Shift-Ctrl-\\", setBlockType(type));
}
if ((type = schema.nodes.heading)) {
for (let i = 1; i <= 6; i++) {
bind("Shift-Ctrl-" + i, setBlockType(type, { level: i }));
}
}
if ((type = schema.nodes.horizontal_rule)) {
bind("Mod-_", (state, dispatch) => {
dispatch?.(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
return true;
});
}
return keys;
}

View File

@ -0,0 +1,27 @@
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
const isEmptyParagraph = (node) => {
return node.type.name === "paragraph" && node.nodeSize === 2;
};
export default (placeholder) => {
return new Plugin({
props: {
decorations(state) {
const { $from } = state.selection;
if (
state.doc.childCount === 1 &&
state.doc.firstChild === $from.parent &&
isEmptyParagraph($from.parent)
) {
const decoration = Decoration.node($from.before(), $from.after(), {
"data-placeholder": placeholder,
});
return DecorationSet.create(state.doc, [decoration]);
}
},
},
});
};

View File

@ -31,8 +31,21 @@
"highlight.js": "11.11.1",
"immer": "^10.1.1",
"jspreadsheet-ce": "^4.15.0",
"lowlight": "^3.2.0",
"morphlex": "^0.0.16",
"pretty-text": "workspace:1.0.0"
"pretty-text": "workspace:1.0.0",
"prosemirror-commands": "^1.6.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-highlight": "^0.11.0",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3"
},
"devDependencies": {
"@babel/core": "^7.26.0",

View File

@ -18,6 +18,7 @@
@import "common/topic-timeline";
@import "common/loading-slider";
@import "common/float-kit/_index";
@import "common/rich-editor";
@import "common/login/_index";
@import "common/table-builder/_index";
@import "common/post-action-feedback";

View File

@ -12,6 +12,7 @@
@import "char-counter";
@import "conditional-loading-section";
@import "calendar-date-time-input";
@import "composer-toggle-switch";
@import "convert-to-public-topic-modal";
@import "d-lightbox";
@import "d-toggle-switch";

View File

@ -0,0 +1,146 @@
.composer-toggle-switch {
$root: &;
--toggle-switch-width: 48px;
--toggle-switch-height: 24px;
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;
&__label {
position: relative;
display: inline-block;
cursor: pointer;
margin: 0;
}
&__checkbox {
position: absolute;
border: 0;
padding: 0;
background: transparent;
&: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;
}
}
}
}
&__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;
}
}
&__checkbox-slider {
display: inline-block;
cursor: pointer;
background: var(--primary-low-mid);
width: var(--toggle-switch-width);
height: var(--toggle-switch-height);
position: relative;
vertical-align: middle;
transition: background 0.25s;
}
&__left-icon {
opacity: 0;
transition: opacity 0.25s;
@media (prefers-reduced-motion: reduce) {
transition-duration: 0ms;
}
&.--active {
opacity: 1;
}
& .d-icon {
font-size: var(--font-down-1);
color: var(--primary);
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

@ -0,0 +1 @@
@import "rich-editor";

View File

@ -0,0 +1,198 @@
.d-editor__container {
margin: 0;
overscroll-behavior: contain;
overflow-anchor: none;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
display: flex;
height: 100%;
}
.d-editor__editable {
outline: 0;
padding: 0 0.625rem;
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 30px 0 10px;
&:first-child {
margin-top: 10px;
}
}
h1 {
font-size: var(--font-up-3-rem);
}
h2 {
font-size: var(--font-up-2-rem);
}
h3 {
font-size: var(--font-up-1-rem);
}
img {
display: inline-block;
margin: 0 auto;
max-width: 100%;
&[data-placeholder="true"] {
animation: placeholder 1.5s infinite;
@keyframes placeholder {
0% {
opacity: 0.6;
}
50% {
opacity: 0.4;
}
100% {
opacity: 0.6;
}
}
}
}
ul,
ol {
padding-left: 1.25em;
&[data-tight="true"] > li > p {
margin-top: 0;
margin-bottom: 0;
}
}
p {
line-height: 1.5;
&:first-child {
margin-top: 0.59rem;
}
}
p[data-placeholder]::before {
pointer-events: none;
position: absolute;
padding-top: 2px;
padding-right: 0.5rem;
content: attr(data-placeholder);
color: var(--primary-400);
line-height: 1.1;
}
del {
background-color: var(--danger-low);
}
ins {
background-color: var(--success-low);
}
mark {
background-color: var(--highlight);
}
td {
padding: 3px 3px 3px 0.5em;
}
th {
padding-bottom: 2px;
font-weight: bold;
color: var(--primary);
}
kbd {
// I believe this shouldn't be `inline-flex` in posts either (test with emojis before/after text to see why),
// but overriding just for the editor for now
display: inline;
padding-top: 0.2rem;
}
}
.d-editor__code-lang-select {
position: absolute;
right: 0.25rem;
top: -0.6rem;
border: 1px solid var(--primary-low);
border-radius: var(--border-radius);
background-color: var(--primary-very-low);
color: var(--primary-medium);
font-size: var(--font-down-1-rem);
}
/*
Everything below was copied from prosemirror-view/style/prosemirror.css
*/
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
/* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */
.ProseMirror [draggable][contenteditable="false"] {
user-select: text;
}
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px;
top: -2px;
bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
/* Protect against generic img rules */
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
}

View File

@ -13,6 +13,7 @@
padding: 6px;
padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)");
flex-grow: 1;
min-height: 0;
}
&.open {

View File

@ -3368,3 +3368,7 @@ experimental:
default: ""
type: group_list
list_type: compact
experimental_rich_editor:
client: true
default: false
hidden: true

View File

@ -7,6 +7,10 @@ in this file.
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).
## [1.40.0] - 2024-12-16
- Added `registerRichEditorExtension` which allows plugins/TCs to register an extension for the rich text editor.
## [1.39.2] - 2024-12-19
- Removed the deprecation of `includePostAttributes` for now.

View File

@ -4,6 +4,7 @@ module SvgSprite
SVG_ICONS =
Set.new(
%w[
a
address-book
align-left
anchor
@ -105,6 +106,7 @@ module SvgSprite
fab-instagram
fab-linkedin-in
fab-linux
fab-markdown
fab-threads
fab-threads-square
fab-twitter

View File

@ -58,10 +58,10 @@ export default {
const inputs = ["input", "textarea", "select", "button"];
const elementTagName = el?.tagName.toLowerCase();
if (inputs.includes(elementTagName)) {
return false;
}
return true;
return (
inputs.includes(elementTagName) ||
!!el?.closest('[contenteditable="true"]')
);
};
const modifyComposerSelection = (event, type) => {
if (!isChatComposer(event.target)) {
@ -87,7 +87,7 @@ export default {
};
const openChatDrawer = (event) => {
if (!isInputSelection(event.target)) {
if (isInputSelection(event.target)) {
return;
}
event.preventDefault();

View File

@ -3,12 +3,14 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { withPluginApi } from "discourse/lib/plugin-api";
import { iconHTML } from "discourse-common/lib/icon-library";
import { i18n } from "discourse-i18n";
import richEditorExtension from "../../lib/rich-editor-extension";
function initializePlugin(api) {
const siteSettings = api.container.lookup("service:site-settings");
if (siteSettings.checklist_enabled) {
api.decorateCookedElement(checklistSyntax);
api.registerRichEditorExtension(richEditorExtension);
}
}

View File

@ -0,0 +1,67 @@
export default {
// TODO(renato): make the checkbox clickable
// TODO(renato): auto-continue checkbox list on ENTER
// TODO(renato): apply .has-checkbox style to the <li> to avoid :has
nodeSpec: {
check: {
attrs: { checked: { default: false } },
inline: true,
group: "inline",
draggable: true,
selectable: false,
toDOM(node) {
return [
"span",
{
class: node.attrs.checked
? "chcklst-box checked fa fa-square-check-o fa-fw"
: "chcklst-box fa fa-square-o fa-fw",
},
];
},
parseDOM: [
{
tag: "span.chcklst-box",
getAttrs: (dom) => {
return { checked: hasCheckedClass(dom.className) };
},
},
],
},
},
inputRules: [
{
match: /(?<=^|\s)\[(x? ?)]$/,
handler: (state, match, start, end) =>
state.tr.replaceWith(
start,
end,
state.schema.nodes.check.create({ checked: match[1] === "x" })
),
options: { undoable: false },
},
],
parse: {
check_open: {
node: "check",
getAttrs: (token) => ({
checked: hasCheckedClass(token.attrGet("class")),
}),
},
check_close: { noCloseToken: true, ignore: true },
},
serializeNode: {
check: (state, node) => {
state.write(node.attrs.checked ? "[x]" : "[ ]");
},
},
};
const CHECKED_REGEX = /\bchecked\b/;
function hasCheckedClass(className) {
return CHECKED_REGEX.test(className);
}

View File

@ -68,6 +68,17 @@ ul li.has-checkbox {
}
}
.d-editor__editable {
// TODO(renato): this is temporary, we should use `has-checkbox` instead
li:has(p:first-of-type > span.chcklst-box) {
list-style-type: none;
.chcklst-box:first-of-type {
margin-left: -1.33em;
}
}
}
.fa-spin {
display: inline-flex;
vertical-align: text-bottom;

View File

@ -1,6 +1,7 @@
import $ from "jquery";
import { withPluginApi } from "discourse/lib/plugin-api";
import { i18n } from "discourse-i18n";
import richEditorExtension from "../lib/rich-editor-extension";
function initializeDetails(api) {
api.decorateCooked(($elem) => $("details", $elem), {
@ -19,6 +20,8 @@ function initializeDetails(api) {
icon: "caret-right",
label: "details.title",
});
api.registerRichEditorExtension(richEditorExtension);
}
export default {

View File

@ -0,0 +1,41 @@
export default {
nodeSpec: {
details: {
content: "summary block+",
group: "block",
defining: true,
parseDOM: [{ tag: "details" }],
toDOM: () => ["details", { open: true }, 0],
},
summary: {
content: "inline*",
group: "block",
parseDOM: [{ tag: "summary" }],
toDOM: () => ["summary", 0],
},
},
parse: {
bbcode(state, token) {
if (token.tag === "details") {
state.openNode(state.schema.nodes.details);
return true;
}
if (token.tag === "summary") {
state.openNode(state.schema.nodes.summary);
return true;
}
},
},
serializeNode: {
details(state, node) {
state.renderContent(node);
state.write("[/details]\n");
},
summary(state, node) {
state.write("[details=");
state.renderContent(node);
state.write("]\n");
},
},
};

View File

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

View File

@ -13,6 +13,7 @@ import { i18n } from "discourse-i18n";
import generateDateMarkup from "discourse/plugins/discourse-local-dates/lib/local-date-markup-generator";
import LocalDatesCreateModal from "../discourse/components/modal/local-dates-create";
import LocalDateBuilder from "../lib/local-date-builder";
import richEditorExtension from "../lib/rich-editor-extension";
// Import applyLocalDates from discourse/lib/local-dates instead
export function applyLocalDates(dates, siteSettings) {
@ -236,6 +237,8 @@ function initializeDiscourseLocalDates(api) {
return "";
}
});
api.registerRichEditorExtension(richEditorExtension);
}
function buildHtmlPreview(element, siteSettings) {

View File

@ -0,0 +1,132 @@
export default {
// TODO the rendered date needs to be localized to better match the cooked content
nodeSpec: {
local_date: {
attrs: { date: {}, time: {}, timezone: { default: null } },
content: "text*",
group: "inline",
atom: true,
inline: true,
parseDOM: [
{
tag: "span.discourse-local-date[data-date]",
getAttrs: (dom) => {
return {
date: dom.getAttribute("data-date"),
time: dom.getAttribute("data-time"),
timezone: dom.getAttribute("data-timezone"),
};
},
},
],
toDOM: (node) => {
const optionalTime = node.attrs.time ? ` ${node.attrs.time}` : "";
return [
"span",
{
class: "discourse-local-date cooked-date",
"data-date": node.attrs.date,
"data-time": node.attrs.time,
"data-timezone": node.attrs.timezone,
},
`${node.attrs.date}${optionalTime}`,
];
},
},
local_date_range: {
attrs: { from: {}, to: { default: null }, timezone: { default: null } },
content: "text*",
group: "inline",
atom: true,
inline: true,
parseDOM: [
{
tag: "span.discourse-local-date[data-from]",
getAttrs: (dom) => {
return {
from: dom.getAttribute("data-from"),
to: dom.getAttribute("data-to"),
timezone: dom.getAttribute("data-timezone"),
};
},
},
],
toDOM: (node) => {
return [
"span",
{ class: "discourse-local-date-wrapper" },
[
"span",
{
class: "discourse-local-date cooked-date",
"data-range": "from",
"data-date": node.attrs.from,
"data-timezone": node.attrs.timezone,
},
`${node.attrs.from}`,
],
" → ",
[
"span",
{
class: "discourse-local-date cooked-date",
"data-range": "to",
"data-date": node.attrs.to,
"data-timezone": node.attrs.timezone,
},
`${node.attrs.to}`,
],
];
},
},
},
parse: {
span: (state, token) => {
if (token.attrGet("class") !== "discourse-local-date") {
return;
}
if (token.attrGet("data-range") === "from") {
state.openNode(state.schema.nodes.local_date_range, {
from: token.attrGet("data-date"),
to: token.attrGet("data-date"),
timezone: token.attrGet("data-timezone"),
});
return;
}
if (token.attrGet("data-range") === "to") {
// we're not supposed to mutate attrs, but we're still building the doc
state.top().attrs.to = token.attrGet("data-date");
return true;
}
state.openNode(state.schema.nodes.local_date, {
date: token.attrGet("data-date"),
time: token.attrGet("data-time"),
timezone: token.attrGet("data-timezone"),
});
return true;
},
},
serializeNode: {
local_date: (state, node) => {
const optionalTime = node.attrs.time ? ` time=${node.attrs.time}` : "";
const optionalTimezone = node.attrs.timezone
? ` timezone="${node.attrs.timezone}"`
: "";
state.write(
`[date=${node.attrs.date}${optionalTime}${optionalTimezone}]`
);
},
local_date_range: (state, node) => {
const optionalTimezone = node.attrs.timezone
? ` timezone="${node.attrs.timezone}"`
: "";
state.write(
`[date-range from=${node.attrs.from} to=${node.attrs.to}${optionalTimezone}]`
);
},
},
};