@dxpos/markdown 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/editor/front-matter/index.d.ts +33 -0
- package/dist/editor/front-matter/index.d.ts.map +1 -0
- package/dist/editor/front-matter/index.js +344 -0
- package/dist/editor/index.d.ts +6 -0
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +31 -3
- package/dist/editor/slash-menu/index.d.ts.map +1 -1
- package/dist/editor/slash-menu/index.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/text-offset.d.ts +6 -0
- package/dist/text-offset.d.ts.map +1 -0
- package/dist/text-offset.js +34 -0
- package/dist/view/image-lightbox/index.d.ts +15 -0
- package/dist/view/image-lightbox/index.d.ts.map +1 -0
- package/dist/view/image-lightbox/index.js +54 -0
- package/dist/view/index.d.ts.map +1 -1
- package/dist/view/index.js +42 -3
- package/package.json +3 -2
- package/styles.css +163 -0
package/README.md
CHANGED
|
@@ -63,6 +63,19 @@ import { Editor } from "@dxpos/markdown";
|
|
|
63
63
|
|
|
64
64
|
`toolbar` 不传时使用当前文档编辑器的默认顺序,也可传入标识数组,例如 `["h1", "h2", "divider", "image"]`。同一组的变体写成 `组:值`,如 `align:center`、`list:bullet`。`divider` 是分隔线,`spacer` 把后面的按钮推到右侧。默认数组从包入口以 `DEFAULT_TOOLBAR` 导出。
|
|
65
65
|
|
|
66
|
+
`initialCaret` 指定编辑器创建时的光标,只在创建时生效。`offset` 是光标之前的非空白字符数,`clientY` 是双击时的屏幕纵坐标,编辑器会把光标行尽量放在这个高度并保证完整可见。配合 `textOffsetAtPoint(root, x, y)` 可以从预览区的点击坐标得到 `offset`:
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
<div
|
|
70
|
+
onDoubleClick={(event) => {
|
|
71
|
+
const offset = textOffsetAtPoint(event.currentTarget, event.clientX, event.clientY);
|
|
72
|
+
startEdit(offset === null ? null : { offset, clientY: event.clientY });
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<Viewer content={markdown} />
|
|
76
|
+
</div>
|
|
77
|
+
```
|
|
78
|
+
|
|
66
79
|
## 渲染
|
|
67
80
|
|
|
68
81
|
插件从子路径按需引入,通过 `plugins` 数组传入,没有默认项。
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Node } from "@tiptap/core";
|
|
2
|
+
type FrontMatterBlockState = {
|
|
3
|
+
src: string;
|
|
4
|
+
bMarks: number[];
|
|
5
|
+
eMarks: number[];
|
|
6
|
+
line: number;
|
|
7
|
+
push: (type: string, tag: string, nesting: number) => {
|
|
8
|
+
content: string;
|
|
9
|
+
map: [number, number] | null;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
type MarkdownItLike = {
|
|
13
|
+
block: {
|
|
14
|
+
ruler: {
|
|
15
|
+
before: (beforeName: string, ruleName: string, fn: (state: FrontMatterBlockState, startLine: number, endLine: number, silent: boolean) => boolean) => void;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
renderer: {
|
|
19
|
+
rules: Record<string, (tokens: Array<{
|
|
20
|
+
content: string;
|
|
21
|
+
}>, idx: number) => string>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** 给 markdown-it 装上 front matter 块规则,重复调用只装一次 */
|
|
25
|
+
export declare function installFrontMatterMarkdown(markdownit: MarkdownItLike): void;
|
|
26
|
+
/** 块内文本为空时不输出,等同于删掉元数据 */
|
|
27
|
+
export declare function serializeFrontMatter(content: string): string;
|
|
28
|
+
/** 文档开头最多一个元数据块,后面至少还有一个正文块 */
|
|
29
|
+
export declare const FrontMatterDocument: Node<any, any>;
|
|
30
|
+
/** 文首 front matter:按 YAML 编辑,序列化回 --- 包裹的块 */
|
|
31
|
+
export declare const FrontMatter: Node<any, any>;
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/editor/front-matter/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,IAAI,EAAmB,MAAM,cAAc,CAAC;AAUhE,KAAK,qBAAqB,GAAG;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;KAAE,CAAC;CACzG,CAAC;AAEF,KAAK,cAAc,GAAG;IACpB,KAAK,EAAE;QACL,KAAK,EAAE;YACL,MAAM,EAAE,CACN,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,CAAC,KAAK,EAAE,qBAAqB,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,OAAO,KAC/F,IAAI,CAAC;SACX,CAAC;KACH,CAAC;IACF,QAAQ,EAAE;QACR,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,EAAE,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;KACpF,CAAC;CACH,CAAC;AA8CF,iDAAiD;AACjD,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,cAAc,GAAG,IAAI,CAS3E;AAED,0BAA0B;AAC1B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAM5D;AAmJD,+BAA+B;AAC/B,eAAO,MAAM,mBAAmB,gBAI9B,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,WAAW,gBAyJtB,CAAC"}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { InputRule, Node, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
import { Fragment } from "@tiptap/pm/model";
|
|
3
|
+
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
|
4
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
5
|
+
import { parseDocument } from "yaml";
|
|
6
|
+
const FENCE_LINE = /^---[ \t]*$/;
|
|
7
|
+
const frontMatterDecorationsKey = new PluginKey("frontMatterFieldKeys");
|
|
8
|
+
const configuredMarkdown = new WeakSet();
|
|
9
|
+
function blockLine(state, line) {
|
|
10
|
+
return state.src.slice(state.bMarks[line], state.eMarks[line]).replace(/\r$/, "");
|
|
11
|
+
}
|
|
12
|
+
function escapeHtml(value) {
|
|
13
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
14
|
+
}
|
|
15
|
+
/** 只认文档第 0 行的 ---,一直取到下一行独立的 --- */
|
|
16
|
+
function frontMatterBlock(state, startLine, endLine, silent) {
|
|
17
|
+
if (startLine !== 0 || !FENCE_LINE.test(blockLine(state, startLine))) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
let closeLine = -1;
|
|
21
|
+
for (let line = startLine + 1; line < endLine; line += 1) {
|
|
22
|
+
if (FENCE_LINE.test(blockLine(state, line))) {
|
|
23
|
+
closeLine = line;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (closeLine < 0) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (silent) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
const content = closeLine === startLine + 1
|
|
34
|
+
? ""
|
|
35
|
+
: state.src.slice(state.bMarks[startLine + 1], state.eMarks[closeLine - 1]).replace(/\r/g, "");
|
|
36
|
+
const token = state.push("front_matter", "pre", 0);
|
|
37
|
+
token.content = content;
|
|
38
|
+
token.map = [startLine, closeLine + 1];
|
|
39
|
+
state.line = closeLine + 1;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/** 给 markdown-it 装上 front matter 块规则,重复调用只装一次 */
|
|
43
|
+
export function installFrontMatterMarkdown(markdownit) {
|
|
44
|
+
if (configuredMarkdown.has(markdownit)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
configuredMarkdown.add(markdownit);
|
|
48
|
+
markdownit.block.ruler.before("hr", "front_matter", frontMatterBlock);
|
|
49
|
+
markdownit.renderer.rules.front_matter = (tokens, idx) => {
|
|
50
|
+
return `<pre data-front-matter>${escapeHtml(tokens[idx]?.content ?? "")}</pre>\n`;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** 块内文本为空时不输出,等同于删掉元数据 */
|
|
54
|
+
export function serializeFrontMatter(content) {
|
|
55
|
+
const body = content.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
56
|
+
if (!body.trim()) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
return `---\n${body}\n---`;
|
|
60
|
+
}
|
|
61
|
+
function keyColon(line, start) {
|
|
62
|
+
if (line[start] === '"' || line[start] === "'") {
|
|
63
|
+
return -1;
|
|
64
|
+
}
|
|
65
|
+
for (let index = start; index < line.length; index += 1) {
|
|
66
|
+
if (line[index] === "#" && (index === start || /\s/.test(line[index - 1] ?? ""))) {
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
69
|
+
if (line[index] === ":") {
|
|
70
|
+
const next = line[index + 1];
|
|
71
|
+
if (next === undefined || next === " " || next === "\t") {
|
|
72
|
+
return index;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return -1;
|
|
77
|
+
}
|
|
78
|
+
function quotedEnd(line, start) {
|
|
79
|
+
const quote = line[start];
|
|
80
|
+
for (let index = start + 1; index < line.length; index += 1) {
|
|
81
|
+
if (line[index] === "\\") {
|
|
82
|
+
index += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (line[index] === quote) {
|
|
86
|
+
return index + 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
function decorateLine(line, linePos, decorations) {
|
|
92
|
+
let index = /^ */.exec(line)?.[0].length ?? 0;
|
|
93
|
+
if (line[index] === "-" && (line[index + 1] === " " || line[index + 1] === undefined)) {
|
|
94
|
+
decorations.push(Decoration.inline(linePos + index, linePos + index + 1, { class: "wsmd-front-matter__bullet" }));
|
|
95
|
+
index += 1;
|
|
96
|
+
while (line[index] === " ") {
|
|
97
|
+
index += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const colon = keyColon(line, index);
|
|
101
|
+
if (colon > index) {
|
|
102
|
+
decorations.push(Decoration.inline(linePos + index, linePos + colon, { class: "wsmd-front-matter__key" }));
|
|
103
|
+
index = colon + 1;
|
|
104
|
+
}
|
|
105
|
+
while (index < line.length) {
|
|
106
|
+
const char = line[index];
|
|
107
|
+
if (char === "#" && (index === 0 || /\s/.test(line[index - 1] ?? ""))) {
|
|
108
|
+
decorations.push(Decoration.inline(linePos + index, linePos + line.length, { class: "wsmd-front-matter__comment" }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (char === '"' || char === "'") {
|
|
112
|
+
const end = quotedEnd(line, index);
|
|
113
|
+
if (end > index) {
|
|
114
|
+
decorations.push(Decoration.inline(linePos + index, linePos + end, { class: "wsmd-front-matter__string" }));
|
|
115
|
+
index = end;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
index += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** 编辑器里的行号按块内可见行计算,不含外面的 --- */
|
|
123
|
+
function frontMatterError(text) {
|
|
124
|
+
if (!text.trim()) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const parsed = parseDocument(text, { prettyErrors: false });
|
|
128
|
+
const error = parsed.errors[0];
|
|
129
|
+
if (error) {
|
|
130
|
+
const offset = error.pos?.[0];
|
|
131
|
+
if (offset === undefined) {
|
|
132
|
+
return "元数据格式无效";
|
|
133
|
+
}
|
|
134
|
+
let line = 1;
|
|
135
|
+
const end = Math.min(offset, text.length);
|
|
136
|
+
for (let index = 0; index < end; index += 1) {
|
|
137
|
+
if (text[index] === "\n") {
|
|
138
|
+
line += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return `第 ${line} 行:元数据格式无效`;
|
|
142
|
+
}
|
|
143
|
+
const data = parsed.toJS();
|
|
144
|
+
if (data !== null && data !== undefined && (typeof data !== "object" || Array.isArray(data))) {
|
|
145
|
+
return "元数据须为键值映射";
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function frontMatterDecorations(doc) {
|
|
150
|
+
const node = doc.firstChild;
|
|
151
|
+
if (!node || node.type.name !== "frontMatter") {
|
|
152
|
+
return DecorationSet.empty;
|
|
153
|
+
}
|
|
154
|
+
const decorations = [];
|
|
155
|
+
const error = frontMatterError(node.textContent);
|
|
156
|
+
if (error) {
|
|
157
|
+
decorations.push(Decoration.node(0, node.nodeSize, {
|
|
158
|
+
class: "wsmd-front-matter--invalid",
|
|
159
|
+
"data-error": error,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
let offset = 0;
|
|
163
|
+
for (const line of node.textContent.split("\n")) {
|
|
164
|
+
decorateLine(line, 1 + offset, decorations);
|
|
165
|
+
offset += line.length + 1;
|
|
166
|
+
}
|
|
167
|
+
return decorations.length > 0 ? DecorationSet.create(doc, decorations) : DecorationSet.empty;
|
|
168
|
+
}
|
|
169
|
+
function replaceLines(tr, from, to, next) {
|
|
170
|
+
const text = tr.doc.textBetween(from, to, "\n");
|
|
171
|
+
if (text === next) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
tr.insertText(next, from, to);
|
|
175
|
+
}
|
|
176
|
+
function indentFrontMatter(tr, from, to, direction) {
|
|
177
|
+
const text = tr.doc.textBetween(from, to, "\n");
|
|
178
|
+
const next = text
|
|
179
|
+
.split("\n")
|
|
180
|
+
.map((line) => {
|
|
181
|
+
if (direction > 0) {
|
|
182
|
+
return ` ${line}`;
|
|
183
|
+
}
|
|
184
|
+
if (line.startsWith(" ")) {
|
|
185
|
+
return line.slice(2);
|
|
186
|
+
}
|
|
187
|
+
if (line.startsWith(" ")) {
|
|
188
|
+
return line.slice(1);
|
|
189
|
+
}
|
|
190
|
+
return line;
|
|
191
|
+
})
|
|
192
|
+
.join("\n");
|
|
193
|
+
replaceLines(tr, from, to, next);
|
|
194
|
+
}
|
|
195
|
+
/** 文档开头最多一个元数据块,后面至少还有一个正文块 */
|
|
196
|
+
export const FrontMatterDocument = Node.create({
|
|
197
|
+
name: "doc",
|
|
198
|
+
topNode: true,
|
|
199
|
+
content: "frontMatter? block+",
|
|
200
|
+
});
|
|
201
|
+
/** 文首 front matter:按 YAML 编辑,序列化回 --- 包裹的块 */
|
|
202
|
+
export const FrontMatter = Node.create({
|
|
203
|
+
name: "frontMatter",
|
|
204
|
+
priority: 1000,
|
|
205
|
+
content: "text*",
|
|
206
|
+
marks: "",
|
|
207
|
+
code: true,
|
|
208
|
+
defining: true,
|
|
209
|
+
isolating: true,
|
|
210
|
+
parseHTML() {
|
|
211
|
+
return [{ tag: "pre[data-front-matter]", preserveWhitespace: "full" }];
|
|
212
|
+
},
|
|
213
|
+
renderHTML({ HTMLAttributes }) {
|
|
214
|
+
return ["pre", mergeAttributes(HTMLAttributes, { "data-front-matter": "", class: "wsmd-front-matter" }), 0];
|
|
215
|
+
},
|
|
216
|
+
addStorage() {
|
|
217
|
+
return {
|
|
218
|
+
markdown: {
|
|
219
|
+
serialize(state, node) {
|
|
220
|
+
const markdown = serializeFrontMatter(node.textContent);
|
|
221
|
+
if (!markdown) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
state.write(markdown);
|
|
225
|
+
state.closeBlock(node);
|
|
226
|
+
},
|
|
227
|
+
parse: {
|
|
228
|
+
setup(markdownit) {
|
|
229
|
+
installFrontMatterMarkdown(markdownit);
|
|
230
|
+
},
|
|
231
|
+
updateDOM(element) {
|
|
232
|
+
const first = element.firstElementChild;
|
|
233
|
+
if (!first?.matches("pre[data-front-matter]") || first.nextElementSibling) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
element.appendChild(element.ownerDocument.createElement("p"));
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
addKeyboardShortcuts() {
|
|
243
|
+
return {
|
|
244
|
+
Backspace: () => {
|
|
245
|
+
const { empty, $anchor } = this.editor.state.selection;
|
|
246
|
+
if (!empty || $anchor.parent.type !== this.type || $anchor.parent.textContent.length > 0) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
const pos = $anchor.before();
|
|
250
|
+
return this.editor.commands.command(({ tr }) => {
|
|
251
|
+
tr.delete(pos, pos + $anchor.parent.nodeSize);
|
|
252
|
+
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(pos, tr.doc.content.size))));
|
|
253
|
+
return true;
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
// YAML 不允许 Tab 缩进,块内 Tab 固定插入两个空格
|
|
257
|
+
Tab: () => {
|
|
258
|
+
const { $from, $to, empty } = this.editor.state.selection;
|
|
259
|
+
if ($from.parent.type !== this.type || !$from.sameParent($to)) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
return this.editor.commands.command(({ tr }) => {
|
|
263
|
+
if (empty) {
|
|
264
|
+
tr.insertText(" ", $from.pos);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
const start = $from.start();
|
|
268
|
+
const text = $from.parent.textContent;
|
|
269
|
+
const lineStart = text.lastIndexOf("\n", Math.max(0, $from.parentOffset - 1)) + 1;
|
|
270
|
+
const lineEndIndex = text.indexOf("\n", $to.parentOffset);
|
|
271
|
+
const lineEnd = lineEndIndex < 0 ? text.length : lineEndIndex;
|
|
272
|
+
indentFrontMatter(tr, start + lineStart, start + lineEnd, 1);
|
|
273
|
+
return true;
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
"Shift-Tab": () => {
|
|
277
|
+
const { $from, $to } = this.editor.state.selection;
|
|
278
|
+
if ($from.parent.type !== this.type || !$from.sameParent($to)) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
return this.editor.commands.command(({ tr }) => {
|
|
282
|
+
const start = $from.start();
|
|
283
|
+
const text = $from.parent.textContent;
|
|
284
|
+
const lineStart = text.lastIndexOf("\n", Math.max(0, $from.parentOffset - 1)) + 1;
|
|
285
|
+
const lineEndIndex = text.indexOf("\n", $to.parentOffset);
|
|
286
|
+
const lineEnd = lineEndIndex < 0 ? text.length : lineEndIndex;
|
|
287
|
+
indentFrontMatter(tr, start + lineStart, start + lineEnd, -1);
|
|
288
|
+
return true;
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
addInputRules() {
|
|
294
|
+
const type = this.type;
|
|
295
|
+
return [
|
|
296
|
+
new InputRule({
|
|
297
|
+
find: /^---$/,
|
|
298
|
+
handler: ({ state, range, commands }) => {
|
|
299
|
+
const $from = state.doc.resolve(range.from);
|
|
300
|
+
const $to = state.doc.resolve(range.to);
|
|
301
|
+
// 只在文首文本块、且还没有元数据时生效;其余位置交给分隔线
|
|
302
|
+
// range.from 是匹配起点,第三笔「-」还没进文档,尾部检查要用 range.to
|
|
303
|
+
if ($from.depth !== 1 ||
|
|
304
|
+
$from.before(1) !== 0 ||
|
|
305
|
+
$from.parent.type === type ||
|
|
306
|
+
$to.parent !== $from.parent ||
|
|
307
|
+
$to.parent.textContent.slice($to.parentOffset).length > 0) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const paragraph = state.schema.nodes.paragraph;
|
|
311
|
+
if (!paragraph) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
const from = $from.before(1);
|
|
315
|
+
const to = $from.after(1);
|
|
316
|
+
const onlyBlock = state.doc.childCount === 1;
|
|
317
|
+
const front = type.create();
|
|
318
|
+
const applied = commands.command(({ tr }) => {
|
|
319
|
+
tr.replaceWith(from, to, onlyBlock ? Fragment.from([front, paragraph.create()]) : front);
|
|
320
|
+
tr.setSelection(TextSelection.create(tr.doc, 1));
|
|
321
|
+
return true;
|
|
322
|
+
});
|
|
323
|
+
return applied ? undefined : null;
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
];
|
|
327
|
+
},
|
|
328
|
+
addProseMirrorPlugins() {
|
|
329
|
+
return [
|
|
330
|
+
new Plugin({
|
|
331
|
+
key: frontMatterDecorationsKey,
|
|
332
|
+
state: {
|
|
333
|
+
init: (_, state) => frontMatterDecorations(state.doc),
|
|
334
|
+
apply: (tr, decorations) => (tr.docChanged ? frontMatterDecorations(tr.doc) : decorations),
|
|
335
|
+
},
|
|
336
|
+
props: {
|
|
337
|
+
decorations(state) {
|
|
338
|
+
return frontMatterDecorationsKey.getState(state) ?? DecorationSet.empty;
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
];
|
|
343
|
+
},
|
|
344
|
+
});
|
package/dist/editor/index.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { type FC } from "react";
|
|
2
2
|
import { type ToolbarItem, type UploadFile } from "./toolbar/items";
|
|
3
|
+
export type InitialCaret = {
|
|
4
|
+
offset: number;
|
|
5
|
+
clientY: number;
|
|
6
|
+
};
|
|
3
7
|
interface EditorProps {
|
|
4
8
|
content: string;
|
|
5
9
|
onChange: (markdown: string) => void;
|
|
6
10
|
onError: (message: string) => void;
|
|
7
11
|
uploadFile: UploadFile;
|
|
8
12
|
toolbar?: readonly ToolbarItem[];
|
|
13
|
+
/** 初始光标:正文非空白字符偏移与屏幕纵坐标,仅在编辑器创建时生效 */
|
|
14
|
+
initialCaret?: InitialCaret | null;
|
|
9
15
|
}
|
|
10
16
|
export declare const Editor: FC<EditorProps>;
|
|
11
17
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/editor/index.tsx"],"names":[],"mappings":"AAmBA,OAAO,EAA4D,KAAK,EAAE,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/editor/index.tsx"],"names":[],"mappings":"AAmBA,OAAO,EAA4D,KAAK,EAAE,EAAE,MAAM,OAAO,CAAC;AAQ1F,OAAO,EAAmB,KAAK,WAAW,EAAE,KAAK,UAAU,EAAuB,MAAM,iBAAiB,CAAC;AAI1G,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,UAAU,WAAW;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IACjC,sCAAsC;IACtC,YAAY,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;CACpC;AAmJD,eAAO,MAAM,MAAM,EAAE,EAAE,CAAC,WAAW,CA2RlC,CAAC"}
|
package/dist/editor/index.js
CHANGED
|
@@ -19,12 +19,15 @@ import { NodeSelection } from "@tiptap/pm/state";
|
|
|
19
19
|
import { GripVertical } from "lucide-react";
|
|
20
20
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
21
21
|
import { Markdown } from "tiptap-markdown";
|
|
22
|
+
import { posAtTextOffset } from "../text-offset";
|
|
23
|
+
import { FrontMatter, FrontMatterDocument } from "./front-matter";
|
|
22
24
|
import { LetterSpacing } from "./letter-spacing";
|
|
23
25
|
import { createSlashCommand } from "./slash-menu";
|
|
24
26
|
import { Toolbar } from "./toolbar";
|
|
25
27
|
import { DEFAULT_TOOLBAR } from "./toolbar/items";
|
|
26
28
|
import { VideoEmbed } from "./video-embed";
|
|
27
29
|
import { parseVideoUrl } from "./video-embed/video-url";
|
|
30
|
+
const CARET_MARGIN = 24;
|
|
28
31
|
const mediaFilesFrom = (data) => {
|
|
29
32
|
const files = data ? Array.from(data.files) : [];
|
|
30
33
|
return {
|
|
@@ -85,6 +88,11 @@ const BlockDragHandle = ({ editorDom, getEditor }) => {
|
|
|
85
88
|
hide();
|
|
86
89
|
return;
|
|
87
90
|
}
|
|
91
|
+
const top = $pos.node(1);
|
|
92
|
+
if (top.type.name === "frontMatter") {
|
|
93
|
+
hide();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
88
96
|
const nodePos = $pos.before(1);
|
|
89
97
|
const coords = editor.view.coordsAtPos(nodePos);
|
|
90
98
|
const editorRect = editor.view.dom.getBoundingClientRect();
|
|
@@ -125,7 +133,9 @@ const BlockDragHandle = ({ editorDom, getEditor }) => {
|
|
|
125
133
|
}
|
|
126
134
|
}, children: _jsx(GripVertical, { "aria-hidden": true }) }));
|
|
127
135
|
};
|
|
128
|
-
export const Editor = ({ content, onChange, onError, uploadFile, toolbar = DEFAULT_TOOLBAR }) => {
|
|
136
|
+
export const Editor = ({ content, onChange, onError, uploadFile, toolbar = DEFAULT_TOOLBAR, initialCaret, }) => {
|
|
137
|
+
const initialCaretRef = useRef(initialCaret);
|
|
138
|
+
const canvasRef = useRef(null);
|
|
129
139
|
const fileInputRef = useRef(null);
|
|
130
140
|
const videoInputRef = useRef(null);
|
|
131
141
|
const editorRef = useRef(null);
|
|
@@ -182,12 +192,15 @@ export const Editor = ({ content, onChange, onError, uploadFile, toolbar = DEFAU
|
|
|
182
192
|
const editor = useEditor({
|
|
183
193
|
immediatelyRender: false,
|
|
184
194
|
extensions: [
|
|
195
|
+
FrontMatterDocument,
|
|
196
|
+
FrontMatter,
|
|
185
197
|
StarterKit.configure({
|
|
198
|
+
document: false,
|
|
186
199
|
link: false,
|
|
187
200
|
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
|
188
201
|
}),
|
|
189
202
|
Placeholder.configure({
|
|
190
|
-
placeholder: "输入 / 唤起命令",
|
|
203
|
+
placeholder: ({ node }) => (node.type.name === "frontMatter" ? "字段: 值" : "输入 / 唤起命令"),
|
|
191
204
|
}),
|
|
192
205
|
Image,
|
|
193
206
|
VideoEmbed,
|
|
@@ -247,6 +260,21 @@ export const Editor = ({ content, onChange, onError, uploadFile, toolbar = DEFAU
|
|
|
247
260
|
setEditorDom(editor.view.dom);
|
|
248
261
|
}
|
|
249
262
|
}, [editor]);
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
const caret = initialCaretRef.current;
|
|
265
|
+
const canvas = canvasRef.current;
|
|
266
|
+
if (!editor || !caret || !canvas) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// focus(0) 会被 Tiptap 视为不聚焦
|
|
270
|
+
editor.commands.focus(caret.offset === 0 ? "start" : posAtTextOffset(editor.state.doc, caret.offset), { scrollIntoView: false });
|
|
271
|
+
const { top, bottom } = editor.view.coordsAtPos(editor.state.selection.from);
|
|
272
|
+
const rect = canvas.getBoundingClientRect();
|
|
273
|
+
const height = bottom - top;
|
|
274
|
+
// 光标行尽量保持在双击时的屏幕高度,并夹在编辑区可视范围内
|
|
275
|
+
const target = Math.min(Math.max(caret.clientY - height / 2, rect.top + CARET_MARGIN), rect.bottom - CARET_MARGIN - height);
|
|
276
|
+
canvas.scrollTop += top - target;
|
|
277
|
+
}, [editor]);
|
|
250
278
|
const getEditor = useCallback(() => editorRef.current, []);
|
|
251
279
|
const onEditorDragEnter = (event) => {
|
|
252
280
|
if (!hasFilePayload(event.dataTransfer)) {
|
|
@@ -297,7 +325,7 @@ export const Editor = ({ content, onChange, onError, uploadFile, toolbar = DEFAU
|
|
|
297
325
|
editor.chain().focus().setVideoEmbed(parsed).run();
|
|
298
326
|
setVideoError(null);
|
|
299
327
|
setVideoOpen(false);
|
|
300
|
-
} }), _jsxs("div", { className: "wsmd-markdown-editor__canvas", children: [editorDom ? _jsx(BlockDragHandle, { editorDom: editorDom, getEditor: getEditor }) : null, _jsx(EditorContent, { editor: editor, className: "wsmd-markdown-editor__content wsmd-markdown-body" })] }), _jsx("input", { ref: fileInputRef, className: "wsmd-markdown-editor__file", type: "file", accept: "image/*", onChange: (event) => {
|
|
328
|
+
} }), _jsxs("div", { ref: canvasRef, className: "wsmd-markdown-editor__canvas", children: [editorDom ? _jsx(BlockDragHandle, { editorDom: editorDom, getEditor: getEditor }) : null, _jsx(EditorContent, { editor: editor, className: "wsmd-markdown-editor__content wsmd-markdown-body" })] }), _jsx("input", { ref: fileInputRef, className: "wsmd-markdown-editor__file", type: "file", accept: "image/*", onChange: (event) => {
|
|
301
329
|
const file = event.target.files?.[0];
|
|
302
330
|
event.target.value = "";
|
|
303
331
|
if (file) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/editor/slash-menu/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAiB,KAAK,MAAM,EAAE,KAAK,KAAK,EAAE,MAAM,eAAe,CAAC;AAClF,OAAmB,EAAE,KAAK,sBAAsB,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACnG,OAAO,EAEL,QAAQ,EAcT,MAAM,cAAc,CAAC;AAGtB,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,KAAK,CAAC;IACb,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,aAAa,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,QAAQ,CAAC;IACtB,OAAO,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAC;CAC7C,CAAC;AAqBF,eAAO,MAAM,WAAW,EAAE,SAAS,EAwElC,CAAC;AAEF,UAAU,cAAe,SAAQ,eAAe,CAAC,SAAS,EAAE,SAAS,CAAC;CAAG;AAEzE,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,OAAO,CAAC;CACvD;AAED,eAAO,MAAM,SAAS,4GAwDpB,CAAC;AAEH,mBAAmB;AACnB,eAAO,MAAM,kBAAkB,GAAI,eAAe,MAAM,IAAI,EAAE,eAAe,MAAM,IAAI,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/editor/slash-menu/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAiB,KAAK,MAAM,EAAE,KAAK,KAAK,EAAE,MAAM,eAAe,CAAC;AAClF,OAAmB,EAAE,KAAK,sBAAsB,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACnG,OAAO,EAEL,QAAQ,EAcT,MAAM,cAAc,CAAC;AAGtB,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,KAAK,CAAC;IACb,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,aAAa,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,QAAQ,CAAC;IACtB,OAAO,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAC;CAC7C,CAAC;AAqBF,eAAO,MAAM,WAAW,EAAE,SAAS,EAwElC,CAAC;AAEF,UAAU,cAAe,SAAQ,eAAe,CAAC,SAAS,EAAE,SAAS,CAAC;CAAG;AAEzE,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,OAAO,CAAC;CACvD;AAED,eAAO,MAAM,SAAS,4GAwDpB,CAAC;AAEH,mBAAmB;AACnB,eAAO,MAAM,kBAAkB,GAAI,eAAe,MAAM,IAAI,EAAE,eAAe,MAAM,IAAI,wBAgDtF,CAAC"}
|
|
@@ -139,6 +139,7 @@ export const createSlashCommand = (onInsertImage, onInsertVideo) => {
|
|
|
139
139
|
Suggestion({
|
|
140
140
|
editor: this.editor,
|
|
141
141
|
char: "/",
|
|
142
|
+
allow: ({ state, range }) => state.doc.resolve(range.from).parent.type.name !== "frontMatter",
|
|
142
143
|
items: ({ query }) => SLASH_ITEMS.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())),
|
|
143
144
|
command: ({ editor, range, props }) => {
|
|
144
145
|
props.command({ editor, range, onInsertImage, onInsertVideo });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { Editor } from "./editor";
|
|
1
|
+
export { Editor, type InitialCaret } from "./editor";
|
|
2
2
|
export { Viewer } from "./view";
|
|
3
|
+
export { textOffsetAtPoint } from "./text-offset";
|
|
3
4
|
export { DEFAULT_TOOLBAR, type ToolbarItem, type UploadFile, type UploadFileResult, type UploadFileType } from "./editor/toolbar/items";
|
|
4
5
|
export type { MarkdownPlugin } from "./plugin";
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,KAAK,gBAAgB,EAAE,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxI,YAAY,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
2
|
+
/** 预览区坐标处光标之前的非空白字符数;预览与编辑器的空白处理不同,只按非空白字符对齐 */
|
|
3
|
+
export declare const textOffsetAtPoint: (root: HTMLElement, x: number, y: number) => number | null;
|
|
4
|
+
/** 文档中第 offset 个非空白字符之后的位置,超出时返回文档末尾 */
|
|
5
|
+
export declare const posAtTextOffset: (doc: ProseMirrorNode, offset: number) => number;
|
|
6
|
+
//# sourceMappingURL=text-offset.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text-offset.d.ts","sourceRoot":"","sources":["../src/text-offset.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,IAAI,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIhE,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,GAAI,MAAM,WAAW,EAAE,GAAG,MAAM,EAAE,GAAG,MAAM,kBASxE,CAAC;AAEF,wCAAwC;AACxC,eAAO,MAAM,eAAe,GAAI,KAAK,eAAe,EAAE,QAAQ,MAAM,WAoBnE,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const WHITESPACE = /\s/;
|
|
2
|
+
/** 预览区坐标处光标之前的非空白字符数;预览与编辑器的空白处理不同,只按非空白字符对齐 */
|
|
3
|
+
export const textOffsetAtPoint = (root, x, y) => {
|
|
4
|
+
const caret = document.caretPositionFromPoint(x, y);
|
|
5
|
+
if (!caret || !root.contains(caret.offsetNode)) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const range = document.createRange();
|
|
9
|
+
range.setStart(root, 0);
|
|
10
|
+
range.setEnd(caret.offsetNode, caret.offset);
|
|
11
|
+
return range.toString().replace(/\s/g, "").length;
|
|
12
|
+
};
|
|
13
|
+
/** 文档中第 offset 个非空白字符之后的位置,超出时返回文档末尾 */
|
|
14
|
+
export const posAtTextOffset = (doc, offset) => {
|
|
15
|
+
let rest = offset;
|
|
16
|
+
let found = doc.content.size;
|
|
17
|
+
doc.descendants((node, pos) => {
|
|
18
|
+
if (rest === 0) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (!node.isText) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
const text = node.text ?? "";
|
|
25
|
+
for (let i = 0; i < text.length; i++) {
|
|
26
|
+
if (!WHITESPACE.test(text[i]) && --rest === 0) {
|
|
27
|
+
found = pos + i + 1;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
});
|
|
33
|
+
return found;
|
|
34
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type FC } from "react";
|
|
2
|
+
export type PreviewImage = {
|
|
3
|
+
src: string;
|
|
4
|
+
alt: string;
|
|
5
|
+
};
|
|
6
|
+
interface ImageLightboxProps {
|
|
7
|
+
images: PreviewImage[];
|
|
8
|
+
index: number;
|
|
9
|
+
onIndexChange: (index: number) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}
|
|
12
|
+
/** 全屏查看预览中的图片,并在文档顺序内切换 */
|
|
13
|
+
export declare const ImageLightbox: FC<ImageLightboxProps>;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/view/image-lightbox/index.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAa,KAAK,EAAE,EAAE,MAAM,OAAO,CAAC;AAG3C,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAmBF,UAAU,kBAAkB;IAC1B,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,2BAA2B;AAC3B,eAAO,MAAM,aAAa,EAAE,EAAE,CAAC,kBAAkB,CA4EhD,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
const keyHandlers = new Set();
|
|
7
|
+
// 模块加载时挂到 window 捕获阶段,早于页面弹窗的 Esc 监听
|
|
8
|
+
if (typeof window !== "undefined") {
|
|
9
|
+
window.addEventListener("keydown", (event) => {
|
|
10
|
+
for (const handler of keyHandlers) {
|
|
11
|
+
handler(event);
|
|
12
|
+
}
|
|
13
|
+
}, true);
|
|
14
|
+
}
|
|
15
|
+
/** 全屏查看预览中的图片,并在文档顺序内切换 */
|
|
16
|
+
export const ImageLightbox = ({ images, index, onIndexChange, onClose }) => {
|
|
17
|
+
const current = images[index];
|
|
18
|
+
const showNav = images.length > 1;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const previous = document.body.style.overflow;
|
|
21
|
+
document.body.style.overflow = "hidden";
|
|
22
|
+
return () => {
|
|
23
|
+
document.body.style.overflow = previous;
|
|
24
|
+
};
|
|
25
|
+
}, []);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const onKeyDown = (event) => {
|
|
28
|
+
if (event.key !== "Escape" && event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
event.stopImmediatePropagation();
|
|
33
|
+
if (event.key === "Escape") {
|
|
34
|
+
onClose();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (event.key === "ArrowLeft" && index > 0) {
|
|
38
|
+
onIndexChange(index - 1);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (event.key === "ArrowRight" && index < images.length - 1) {
|
|
42
|
+
onIndexChange(index + 1);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
keyHandlers.add(onKeyDown);
|
|
46
|
+
return () => {
|
|
47
|
+
keyHandlers.delete(onKeyDown);
|
|
48
|
+
};
|
|
49
|
+
}, [images.length, index, onClose, onIndexChange]);
|
|
50
|
+
if (!current) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return createPortal(_jsxs("div", { className: "wsmd-image-lightbox", role: "dialog", "aria-modal": "true", "aria-label": "\u56FE\u7247\u9884\u89C8", children: [_jsx("button", { className: "wsmd-image-lightbox__backdrop", type: "button", "aria-label": "\u5173\u95ED\u9884\u89C8", onClick: onClose }), _jsx("button", { className: "wsmd-image-lightbox__close", type: "button", "aria-label": "\u5173\u95ED", onClick: onClose, children: _jsx(X, {}) }), showNav ? (_jsx("button", { className: "wsmd-image-lightbox__nav wsmd-image-lightbox__nav--prev", type: "button", "aria-label": "\u4E0A\u4E00\u5F20", disabled: index === 0, onClick: () => onIndexChange(index - 1), children: _jsx(ChevronLeft, {}) })) : null, _jsx("img", { className: "wsmd-image-lightbox__image", src: current.src, alt: current.alt }), showNav ? (_jsx("button", { className: "wsmd-image-lightbox__nav wsmd-image-lightbox__nav--next", type: "button", "aria-label": "\u4E0B\u4E00\u5F20", disabled: index === images.length - 1, onClick: () => onIndexChange(index + 1), children: _jsx(ChevronRight, {}) })) : null, _jsxs("p", { className: "wsmd-image-lightbox__counter", children: [index + 1, " / ", images.length] })] }), document.body);
|
|
54
|
+
};
|
package/dist/view/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/view/index.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/view/index.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGhD,UAAU,WAAW;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA2BD,eAAO,MAAM,MAAM,wGAkDjB,CAAC"}
|
package/dist/view/index.js
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { forwardRef } from "react";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useEffect, useState } from "react";
|
|
4
4
|
import Markdown from "react-markdown";
|
|
5
|
+
import { ImageLightbox } from "./image-lightbox";
|
|
6
|
+
const previewSrc = (image) => {
|
|
7
|
+
if (!image.getAttribute("src")) {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
return image.currentSrc || image.src;
|
|
11
|
+
};
|
|
12
|
+
const previewImages = (root) => {
|
|
13
|
+
return Array.from(root.querySelectorAll("img")).flatMap((node) => {
|
|
14
|
+
if (!(node instanceof HTMLImageElement) || node.closest("a")) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const src = previewSrc(node);
|
|
18
|
+
if (!src) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return [{ node, image: { src, alt: node.alt } }];
|
|
22
|
+
});
|
|
23
|
+
};
|
|
5
24
|
export const Viewer = forwardRef(({ content, plugins = [], className }, ref) => {
|
|
25
|
+
const [gallery, setGallery] = useState(null);
|
|
6
26
|
const remarkPlugins = [];
|
|
7
27
|
const rehypePlugins = [];
|
|
8
28
|
for (const plugin of plugins) {
|
|
@@ -13,6 +33,25 @@ export const Viewer = forwardRef(({ content, plugins = [], className }, ref) =>
|
|
|
13
33
|
rehypePlugins.push(plugin.rehype);
|
|
14
34
|
}
|
|
15
35
|
}
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setGallery(null);
|
|
38
|
+
}, [content]);
|
|
39
|
+
const onClick = (event) => {
|
|
40
|
+
const target = event.target;
|
|
41
|
+
if (!(target instanceof Element)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const image = target.closest("img");
|
|
45
|
+
if (!(image instanceof HTMLImageElement) || !event.currentTarget.contains(image) || image.closest("a")) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const images = previewImages(event.currentTarget);
|
|
49
|
+
const index = images.findIndex((item) => item.node === image);
|
|
50
|
+
if (index < 0) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
setGallery({ images: images.map((item) => item.image), index });
|
|
54
|
+
};
|
|
16
55
|
const rootClass = className ? `wsmd-markdown-body ${className}` : "wsmd-markdown-body";
|
|
17
|
-
return (
|
|
56
|
+
return (_jsxs("div", { ref: ref, className: rootClass, onClick: onClick, children: [_jsx(Markdown, { remarkPlugins: remarkPlugins, rehypePlugins: rehypePlugins, children: content }), gallery ? (_jsx(ImageLightbox, { images: gallery.images, index: gallery.index, onIndexChange: (index) => setGallery({ images: gallery.images, index }), onClose: () => setGallery(null) })) : null] }));
|
|
18
57
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxpos/markdown",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Markdown 编辑器与渲染器",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
"rehype-highlight": "^7.0.2",
|
|
56
56
|
"rehype-raw": "^7.0.0",
|
|
57
57
|
"remark-gfm": "^4.0.1",
|
|
58
|
-
"tiptap-markdown": "^0.9.0"
|
|
58
|
+
"tiptap-markdown": "^0.9.0",
|
|
59
|
+
"yaml": "2.9.0"
|
|
59
60
|
},
|
|
60
61
|
"license": "MIT"
|
|
61
62
|
}
|
package/styles.css
CHANGED
|
@@ -303,6 +303,105 @@
|
|
|
303
303
|
border-radius: 8px;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
.wsmd-markdown-body img {
|
|
307
|
+
cursor: zoom-in;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.wsmd-markdown-body a img,
|
|
311
|
+
.wsmd-tiptap img {
|
|
312
|
+
cursor: auto;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.wsmd-image-lightbox {
|
|
316
|
+
position: fixed;
|
|
317
|
+
inset: 0;
|
|
318
|
+
z-index: 1000;
|
|
319
|
+
display: flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
justify-content: center;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.wsmd-image-lightbox__backdrop {
|
|
325
|
+
position: absolute;
|
|
326
|
+
inset: 0;
|
|
327
|
+
border: 0;
|
|
328
|
+
padding: 0;
|
|
329
|
+
background: rgb(0 0 0 / 72%);
|
|
330
|
+
cursor: zoom-out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.wsmd-image-lightbox__image {
|
|
334
|
+
position: relative;
|
|
335
|
+
z-index: 1;
|
|
336
|
+
max-width: calc(100% - 128px);
|
|
337
|
+
max-height: calc(100% - 96px);
|
|
338
|
+
object-fit: contain;
|
|
339
|
+
border-radius: 8px;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.wsmd-image-lightbox__close,
|
|
343
|
+
.wsmd-image-lightbox__nav {
|
|
344
|
+
position: absolute;
|
|
345
|
+
z-index: 2;
|
|
346
|
+
display: inline-flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
justify-content: center;
|
|
349
|
+
width: 40px;
|
|
350
|
+
height: 40px;
|
|
351
|
+
padding: 0;
|
|
352
|
+
border: 0;
|
|
353
|
+
border-radius: 999px;
|
|
354
|
+
background: rgb(0 0 0 / 45%);
|
|
355
|
+
color: #fff;
|
|
356
|
+
cursor: pointer;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.wsmd-image-lightbox__close:hover,
|
|
360
|
+
.wsmd-image-lightbox__nav:hover:not(:disabled) {
|
|
361
|
+
background: rgb(255 255 255 / 20%);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.wsmd-image-lightbox__nav:disabled {
|
|
365
|
+
opacity: 0.35;
|
|
366
|
+
cursor: not-allowed;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.wsmd-image-lightbox__close svg,
|
|
370
|
+
.wsmd-image-lightbox__nav svg {
|
|
371
|
+
width: 20px;
|
|
372
|
+
height: 20px;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.wsmd-image-lightbox__close {
|
|
376
|
+
top: 16px;
|
|
377
|
+
right: 16px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.wsmd-image-lightbox__nav {
|
|
381
|
+
top: 50%;
|
|
382
|
+
transform: translateY(-50%);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.wsmd-image-lightbox__nav--prev {
|
|
386
|
+
left: 16px;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.wsmd-image-lightbox__nav--next {
|
|
390
|
+
right: 16px;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.wsmd-image-lightbox__counter {
|
|
394
|
+
position: absolute;
|
|
395
|
+
bottom: 16px;
|
|
396
|
+
left: 50%;
|
|
397
|
+
z-index: 2;
|
|
398
|
+
margin: 0;
|
|
399
|
+
transform: translateX(-50%);
|
|
400
|
+
color: #fff;
|
|
401
|
+
font-family: var(--wsmd-font-mono), ui-monospace, monospace;
|
|
402
|
+
font-size: 13px;
|
|
403
|
+
}
|
|
404
|
+
|
|
306
405
|
.wsmd-markdown-editor__loading {
|
|
307
406
|
padding: 20px 24px;
|
|
308
407
|
color: var(--wsmd-muted);
|
|
@@ -532,6 +631,70 @@
|
|
|
532
631
|
pointer-events: none;
|
|
533
632
|
}
|
|
534
633
|
|
|
634
|
+
.wsmd-markdown-body pre.wsmd-front-matter {
|
|
635
|
+
margin: 0 0 16px;
|
|
636
|
+
padding: 10px 14px 12px;
|
|
637
|
+
border: 1px solid var(--wsmd-border);
|
|
638
|
+
border-radius: 8px;
|
|
639
|
+
background: var(--wsmd-surface);
|
|
640
|
+
color: var(--wsmd-foreground);
|
|
641
|
+
font-family: var(--wsmd-font-mono), ui-monospace, monospace;
|
|
642
|
+
font-size: 13px;
|
|
643
|
+
line-height: 1.6;
|
|
644
|
+
white-space: pre-wrap;
|
|
645
|
+
word-break: break-word;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.wsmd-front-matter::before {
|
|
649
|
+
content: "元数据";
|
|
650
|
+
display: block;
|
|
651
|
+
margin-bottom: 6px;
|
|
652
|
+
color: var(--wsmd-muted);
|
|
653
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
654
|
+
font-size: 12px;
|
|
655
|
+
line-height: 1.4;
|
|
656
|
+
white-space: normal;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.wsmd-front-matter__key {
|
|
660
|
+
color: var(--wsmd-muted);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.wsmd-front-matter__bullet {
|
|
664
|
+
color: var(--wsmd-hl-bullet);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.wsmd-front-matter__comment {
|
|
668
|
+
color: var(--wsmd-hl-comment);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.wsmd-front-matter__string {
|
|
672
|
+
color: var(--wsmd-hl-string);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.wsmd-markdown-body pre.wsmd-front-matter.wsmd-front-matter--invalid {
|
|
676
|
+
border-color: var(--wsmd-danger);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.wsmd-tiptap .wsmd-front-matter.is-empty::after {
|
|
680
|
+
content: attr(data-placeholder);
|
|
681
|
+
color: var(--wsmd-muted);
|
|
682
|
+
font-family: var(--wsmd-font-mono), ui-monospace, monospace;
|
|
683
|
+
pointer-events: none;
|
|
684
|
+
white-space: normal;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.wsmd-tiptap .wsmd-front-matter.wsmd-front-matter--invalid::after {
|
|
688
|
+
content: attr(data-error);
|
|
689
|
+
display: block;
|
|
690
|
+
margin-top: 6px;
|
|
691
|
+
color: var(--wsmd-danger);
|
|
692
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
693
|
+
font-size: 12px;
|
|
694
|
+
line-height: 1.4;
|
|
695
|
+
white-space: normal;
|
|
696
|
+
}
|
|
697
|
+
|
|
535
698
|
.wsmd-tiptap ul[data-type="taskList"] {
|
|
536
699
|
list-style: none;
|
|
537
700
|
padding-left: 0;
|