DEV: prosemirror
This commit is contained in:
parent
eb58623b11
commit
adea7f535d
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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;
|
|
@ -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}}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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)) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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 },
|
||||
};
|
|
@ -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}:`);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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}`);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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];
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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 })
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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);
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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}`);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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() {},
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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() {},
|
||||
},
|
||||
};
|
|
@ -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]))
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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");
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
@import "rich-editor";
|
|
@ -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;
|
||||
}
|
|
@ -13,6 +13,7 @@
|
|||
padding: 6px;
|
||||
padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)");
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&.open {
|
||||
|
|
|
@ -3368,3 +3368,7 @@ experimental:
|
|||
default: ""
|
||||
type: group_list
|
||||
list_type: compact
|
||||
experimental_rich_editor:
|
||||
client: true
|
||||
default: false
|
||||
hidden: true
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}]`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue