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