@dxpos/markdown 0.0.2 → 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 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ });
@@ -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;AAM1F,OAAO,EAAmB,KAAK,WAAW,EAAE,KAAK,UAAU,EAAuB,MAAM,iBAAiB,CAAC;AAI1G,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;CAClC;AA4ID,eAAO,MAAM,MAAM,EAAE,EAAE,CAAC,WAAW,CAyPlC,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"}
@@ -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,wBA+CtF,CAAC"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,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"}
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
@@ -1,4 +1,5 @@
1
1
  "use client";
2
2
  export { Editor } from "./editor";
3
3
  export { Viewer } from "./view";
4
+ export { textOffsetAtPoint } from "./text-offset";
4
5
  export { DEFAULT_TOOLBAR } from "./editor/toolbar/items";
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxpos/markdown",
3
- "version": "0.0.2",
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
@@ -631,6 +631,70 @@
631
631
  pointer-events: none;
632
632
  }
633
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
+
634
698
  .wsmd-tiptap ul[data-type="taskList"] {
635
699
  list-style: none;
636
700
  padding-left: 0;