Skip to content

Commit aca011f

Browse files
authored
feat(editor-plugin): add external plugin support (#141)
1 parent 16a363f commit aca011f

File tree

21 files changed

+1804
-73
lines changed

21 files changed

+1804
-73
lines changed

site/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import packageJson from "../package.json";
33
import type { StacksEditor, StacksEditorOptions } from "../src";
44
import type { LinkPreviewProvider } from "../src/rich-text/plugins/link-preview";
55
import type { ImageUploadOptions } from "../src/shared/prosemirror-plugins/image-upload";
6+
import { samplePlugins } from "./sample-plugins";
67
import "./site.less";
78

89
function domReady(callback: (e: Event) => void) {
@@ -150,6 +151,7 @@ domReady(() => {
150151
const content = document.querySelector<HTMLTextAreaElement>("#content");
151152
const enableTables = place.classList.contains("js-tables-enabled");
152153
const enableImages = !place.classList.contains("js-images-disabled");
154+
const enableSamplePlugin = place.classList.contains("js-plugins-enabled");
153155

154156
const imageUploadOptions: ImageUploadOptions = {
155157
handler: ImageUploadHandler,
@@ -206,7 +208,7 @@ domReady(() => {
206208
],
207209
},
208210
imageUpload: imageUploadOptions,
209-
externalPlugins: [],
211+
editorPlugins: enableSamplePlugin ? samplePlugins : [],
210212
};
211213

212214
const editorInstance = new StacksEditor(place, content.value, options);

site/layout.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@
9494
Dual editor playground
9595
</a>
9696
</li>
97+
<li class="s-sidebarwidget--item">
98+
<a href="./plugins.html" class="s-link">
99+
Plugins playground
100+
</a>
101+
</li>
97102
<li class="s-sidebarwidget--item">
98103
v<span class="js-version-number">???</span>
99104
</li>

site/sample-plugins/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { japaneseSEPlugin } from "./japanese-se";
2+
import { mermaidPlugin } from "./mermaid";
3+
import { sillyPlugin } from "./silly-effects";
4+
5+
export const samplePlugins = [japaneseSEPlugin, mermaidPlugin, sillyPlugin];

site/sample-plugins/japanese-se.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import type MarkdownIt from "markdown-it";
2+
import type ParserInline from "markdown-it/lib/parser_inline";
3+
import type StateInline from "markdown-it/lib/rules_inline/state_inline";
4+
import type Token from "markdown-it/lib/token";
5+
import type { EditorPlugin } from "../../src";
6+
7+
// simple proof of concept that adds furigana support from https://japanese.meta.stackexchange.com/questions/806/how-should-i-format-my-questions-on-japanese-language-se/807#807
8+
// due to the fact that we cannot directly alter the contenteditable content, we have to make these a node or mark
9+
// NOTE: functionality heavily inspired by https://cdn.sstatic.net/Js/third-party/japanese-l-u.js
10+
export const japaneseSEPlugin: EditorPlugin = () => ({
11+
markdown: {
12+
parser: {
13+
jse_furigana: {
14+
mark: "jse_furigana",
15+
getAttrs: (token: Token) => {
16+
return {
17+
text: token.content,
18+
markup: token.attrGet("markup"),
19+
};
20+
},
21+
},
22+
},
23+
serializers: {
24+
nodes: {},
25+
marks: {
26+
jse_furigana: {
27+
open: (_, mark) => mark.attrs.markup as string,
28+
close: (_, mark) => {
29+
const markup = mark.attrs.markup as string;
30+
return markup === "{" ? "}" : "】";
31+
},
32+
},
33+
},
34+
},
35+
alterMarkdownIt: (mdit) => {
36+
mdit.use((md: MarkdownIt) => {
37+
md.inline.ruler.push("jse", mdJSEPlugin);
38+
});
39+
},
40+
},
41+
extendSchema: (schema) => {
42+
schema.marks = schema.marks.addToEnd("jse_furigana", {
43+
attrs: {
44+
text: { default: "" },
45+
markup: { default: "" },
46+
},
47+
toDOM: (mark) => {
48+
return [
49+
"span",
50+
{
51+
"class": "jse-furigana",
52+
"data-text": mark.attrs.text as string,
53+
},
54+
];
55+
},
56+
parseDOM: [
57+
{
58+
tag: "span.jse-furigana",
59+
},
60+
{
61+
tag: "span.rt",
62+
},
63+
],
64+
});
65+
66+
return schema;
67+
},
68+
});
69+
70+
function findEndChar(
71+
state: StateInline,
72+
start: number,
73+
disableNested: boolean,
74+
startCharCode: number,
75+
endCharCode: number
76+
) {
77+
let level,
78+
found,
79+
marker,
80+
prevPos,
81+
labelEnd = -1;
82+
const max = state.posMax,
83+
oldPos = state.pos;
84+
85+
state.pos = start + 1;
86+
level = 1;
87+
88+
while (state.pos < max) {
89+
marker = state.src.charCodeAt(state.pos);
90+
if (marker === endCharCode) {
91+
level--;
92+
if (level === 0) {
93+
found = true;
94+
break;
95+
}
96+
}
97+
98+
prevPos = state.pos;
99+
state.md.inline.skipToken(state);
100+
if (marker === startCharCode) {
101+
if (prevPos === state.pos - 1) {
102+
// increase level if we find text `startCharCode`, which is not a part of any token
103+
level++;
104+
} else if (disableNested) {
105+
state.pos = oldPos;
106+
return -1;
107+
}
108+
}
109+
}
110+
111+
if (found) {
112+
labelEnd = state.pos;
113+
}
114+
115+
// restore old state
116+
state.pos = oldPos;
117+
118+
return labelEnd;
119+
}
120+
121+
const mdJSEPlugin: ParserInline.RuleInline = function (state, silent) {
122+
const startCharCode = state.src.charCodeAt(state.pos);
123+
124+
// quick fail on first character
125+
if (startCharCode !== 0x7b /* { */ && startCharCode !== 0x3010 /* 【 */) {
126+
return false;
127+
}
128+
129+
const endCharCode =
130+
startCharCode === 0x7b /* { */ ? 0x7d /* } */ : 0x3011; /* 】 */
131+
132+
const endCharPos = findEndChar(
133+
state,
134+
state.pos + 1,
135+
false,
136+
startCharCode,
137+
endCharCode
138+
);
139+
140+
if (endCharPos < 0) {
141+
return false;
142+
}
143+
144+
if (!silent) {
145+
const totalContent = state.src.slice(state.pos, endCharPos + 1);
146+
const text = totalContent.slice(1, -1);
147+
148+
let token = state.push("jse_furigana_open", "span", 1);
149+
token.attrSet("markup", String.fromCharCode(startCharCode));
150+
token.content = text;
151+
152+
token = state.push("text", "", 0);
153+
token.content = text;
154+
155+
token = state.push("jse_furigana_close", "span", -1);
156+
token.attrSet("markup", String.fromCharCode(endCharCode));
157+
}
158+
159+
state.pos = endCharPos + 1;
160+
return true;
161+
};

site/sample-plugins/mermaid.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { EditorPlugin } from "../../src";
2+
3+
// NOTE: loaded from cdn in views/plugin.html
4+
declare global {
5+
interface Window {
6+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7+
mermaid: any;
8+
}
9+
}
10+
11+
// simple proof of concept that adds mermaid support
12+
export const mermaidPlugin: EditorPlugin = () => ({
13+
codeBlockProcessors: [
14+
{
15+
lang: "mermaid",
16+
callback: (content, container) => {
17+
// TODO support returning promises?
18+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
19+
window.mermaid.render("TODO_ID", content, (svg: string) => {
20+
// This is a sample plugin and mermaid sanitizes for us, so we don't need to worry about XSS
21+
// eslint-disable-next-line no-unsanitized/property
22+
container.innerHTML = svg;
23+
});
24+
25+
return true;
26+
},
27+
},
28+
],
29+
});

0 commit comments

Comments
 (0)