@dxpos/markdown 0.0.1

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.
Files changed (93) hide show
  1. package/README.md +77 -0
  2. package/dist/editor/ColorMenu.d.ts +18 -0
  3. package/dist/editor/ColorMenu.d.ts.map +1 -0
  4. package/dist/editor/ColorMenu.js +90 -0
  5. package/dist/editor/SlashMenu.d.ts +24 -0
  6. package/dist/editor/SlashMenu.d.ts.map +1 -0
  7. package/dist/editor/SlashMenu.js +179 -0
  8. package/dist/editor/TextStyleMenu.d.ts +45 -0
  9. package/dist/editor/TextStyleMenu.d.ts.map +1 -0
  10. package/dist/editor/TextStyleMenu.js +65 -0
  11. package/dist/editor/VideoEmbed.d.ts +15 -0
  12. package/dist/editor/VideoEmbed.d.ts.map +1 -0
  13. package/dist/editor/VideoEmbed.js +95 -0
  14. package/dist/editor/VideoMenu.d.ts +12 -0
  15. package/dist/editor/VideoMenu.d.ts.map +1 -0
  16. package/dist/editor/VideoMenu.js +35 -0
  17. package/dist/editor/color-menu/index.d.ts +19 -0
  18. package/dist/editor/color-menu/index.d.ts.map +1 -0
  19. package/dist/editor/color-menu/index.js +90 -0
  20. package/dist/editor/color-menu/text-color-presets.d.ts +9 -0
  21. package/dist/editor/color-menu/text-color-presets.d.ts.map +1 -0
  22. package/dist/editor/color-menu/text-color-presets.js +46 -0
  23. package/dist/editor/index.d.ts +12 -0
  24. package/dist/editor/index.d.ts.map +1 -0
  25. package/dist/editor/index.js +314 -0
  26. package/dist/editor/letter-spacing/index.d.ts +17 -0
  27. package/dist/editor/letter-spacing/index.d.ts.map +1 -0
  28. package/dist/editor/letter-spacing/index.js +35 -0
  29. package/dist/editor/letter-spacing.d.ts +17 -0
  30. package/dist/editor/letter-spacing.d.ts.map +1 -0
  31. package/dist/editor/letter-spacing.js +35 -0
  32. package/dist/editor/slash-menu/index.d.ts +25 -0
  33. package/dist/editor/slash-menu/index.d.ts.map +1 -0
  34. package/dist/editor/slash-menu/index.js +179 -0
  35. package/dist/editor/text-color-presets.d.ts +9 -0
  36. package/dist/editor/text-color-presets.d.ts.map +1 -0
  37. package/dist/editor/text-color-presets.js +46 -0
  38. package/dist/editor/text-style-menu/index.d.ts +46 -0
  39. package/dist/editor/text-style-menu/index.d.ts.map +1 -0
  40. package/dist/editor/text-style-menu/index.js +65 -0
  41. package/dist/editor/theme.d.ts +8 -0
  42. package/dist/editor/theme.d.ts.map +1 -0
  43. package/dist/editor/theme.js +18 -0
  44. package/dist/editor/toolbar/index.d.ts +17 -0
  45. package/dist/editor/toolbar/index.d.ts.map +1 -0
  46. package/dist/editor/toolbar/index.js +206 -0
  47. package/dist/editor/toolbar/items.d.ts +10 -0
  48. package/dist/editor/toolbar/items.d.ts.map +1 -0
  49. package/dist/editor/toolbar/items.js +34 -0
  50. package/dist/editor/toolbar/theme.d.ts +8 -0
  51. package/dist/editor/toolbar/theme.d.ts.map +1 -0
  52. package/dist/editor/toolbar/theme.js +18 -0
  53. package/dist/editor/toolbar-items.d.ts +10 -0
  54. package/dist/editor/toolbar-items.d.ts.map +1 -0
  55. package/dist/editor/toolbar-items.js +34 -0
  56. package/dist/editor/toolbar.d.ts +16 -0
  57. package/dist/editor/toolbar.d.ts.map +1 -0
  58. package/dist/editor/toolbar.js +206 -0
  59. package/dist/editor/use-dom-id.d.ts +3 -0
  60. package/dist/editor/use-dom-id.d.ts.map +1 -0
  61. package/dist/editor/use-dom-id.js +11 -0
  62. package/dist/editor/video-embed/index.d.ts +15 -0
  63. package/dist/editor/video-embed/index.d.ts.map +1 -0
  64. package/dist/editor/video-embed/index.js +95 -0
  65. package/dist/editor/video-embed/video-url.d.ts +8 -0
  66. package/dist/editor/video-embed/video-url.d.ts.map +1 -0
  67. package/dist/editor/video-embed/video-url.js +102 -0
  68. package/dist/editor/video-menu/index.d.ts +13 -0
  69. package/dist/editor/video-menu/index.d.ts.map +1 -0
  70. package/dist/editor/video-menu/index.js +35 -0
  71. package/dist/editor/video-url.d.ts +8 -0
  72. package/dist/editor/video-url.d.ts.map +1 -0
  73. package/dist/editor/video-url.js +102 -0
  74. package/dist/index.d.ts +5 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +4 -0
  77. package/dist/plugin.d.ts +6 -0
  78. package/dist/plugin.d.ts.map +1 -0
  79. package/dist/plugin.js +1 -0
  80. package/dist/plugins/gfm/index.d.ts +3 -0
  81. package/dist/plugins/gfm/index.d.ts.map +1 -0
  82. package/dist/plugins/gfm/index.js +2 -0
  83. package/dist/plugins/highlight/index.d.ts +3 -0
  84. package/dist/plugins/highlight/index.d.ts.map +1 -0
  85. package/dist/plugins/highlight/index.js +2 -0
  86. package/dist/plugins/raw/index.d.ts +3 -0
  87. package/dist/plugins/raw/index.d.ts.map +1 -0
  88. package/dist/plugins/raw/index.js +2 -0
  89. package/dist/view/index.d.ts +9 -0
  90. package/dist/view/index.d.ts.map +1 -0
  91. package/dist/view/index.js +18 -0
  92. package/package.json +61 -0
  93. package/styles.css +760 -0
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @dxpos/markdown
2
+
3
+ Markdown 编辑器与渲染器。编辑器基于 TipTap,渲染器基于 react-markdown。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pnpm add @dxpos/markdown
9
+ ```
10
+
11
+ 同时需要 `react` 和 `react-dom`(`^18` 或 `^19`)。组件使用 `useId`,最低支持 React 18。
12
+
13
+ 在应用入口引入样式:
14
+
15
+ ```ts
16
+ import "@dxpos/markdown/styles.css";
17
+ ```
18
+
19
+ ## CSS 变量
20
+
21
+ 主题变量由宿主提供。代码高亮变量在样式里已有默认值,宿主可以覆盖。
22
+
23
+ | 变量 | 影响 |
24
+ | --- | --- |
25
+ | `--wsmd-background` | 代码块、颜色输入和菜单输入的背景 |
26
+ | `--wsmd-foreground` | 强调文字、选中描边、菜单标题 |
27
+ | `--wsmd-surface` | 菜单面板和行内代码背景 |
28
+ | `--wsmd-border` | 分割线、表格边框、引用左边线和菜单边框 |
29
+ | `--wsmd-muted` | 次要文字、占位符、引用文字和工具栏图标 |
30
+ | `--wsmd-accent` | 拖入文件时编辑区的高亮描边 |
31
+ | `--wsmd-danger` | 视频链接无法识别时的错误文字 |
32
+ | `--wsmd-font-mono` | 行内代码和代码块字体 |
33
+ | `--wsmd-hl-base` | 代码高亮的默认文字色 |
34
+ | `--wsmd-hl-keyword` | 关键字 |
35
+ | `--wsmd-hl-title` | 函数名、类名 |
36
+ | `--wsmd-hl-constant` | 数字、属性、字面量 |
37
+ | `--wsmd-hl-string` | 字符串 |
38
+ | `--wsmd-hl-built-in` | 内置符号 |
39
+ | `--wsmd-hl-comment` | 注释 |
40
+ | `--wsmd-hl-tag` | 标签名 |
41
+ | `--wsmd-hl-section` | 标题标记 |
42
+ | `--wsmd-hl-bullet` | 列表标记 |
43
+ | `--wsmd-hl-add-fg` / `--wsmd-hl-add-bg` | diff 新增行的文字和背景 |
44
+ | `--wsmd-hl-del-fg` / `--wsmd-hl-del-bg` | diff 删除行的文字和背景 |
45
+
46
+ 类名以 `wsmd-` 开头,例如 `wsmd-markdown-body`、`wsmd-toolbar`。
47
+
48
+ ## 编辑
49
+
50
+ ```tsx
51
+ import { Editor } from "@dxpos/markdown";
52
+
53
+ <Editor
54
+ content={markdown}
55
+ onChange={setMarkdown}
56
+ onError={(message) => console.error(message)}
57
+ uploadFile={async (file, type) => {
58
+ const uploaded = await upload(file);
59
+ return { id: uploaded.id, url: uploaded.url, extra: {} };
60
+ }}
61
+ />
62
+ ```
63
+
64
+ `toolbar` 不传时使用当前文档编辑器的默认顺序,也可传入标识数组,例如 `["h1", "h2", "divider", "image"]`。同一组的变体写成 `组:值`,如 `align:center`、`list:bullet`。`divider` 是分隔线,`spacer` 把后面的按钮推到右侧。默认数组从包入口以 `DEFAULT_TOOLBAR` 导出。
65
+
66
+ ## 渲染
67
+
68
+ 插件从子路径按需引入,通过 `plugins` 数组传入,没有默认项。
69
+
70
+ ```tsx
71
+ import { Viewer } from "@dxpos/markdown";
72
+ import { gfm } from "@dxpos/markdown/plugins/gfm";
73
+ import { raw } from "@dxpos/markdown/plugins/raw";
74
+ import { highlight } from "@dxpos/markdown/plugins/highlight";
75
+
76
+ <Viewer content={markdown} plugins={[gfm, raw, highlight]} />
77
+ ```
@@ -0,0 +1,18 @@
1
+ export declare const PRESET_HIGHLIGHT_COLORS: readonly ["#fde047", "#fdba74", "#fca5a5", "#f9a8d4", "#d8b4fe", "#93c5fd", "#67e8f9", "#86efac", "#d4d4d8"];
2
+ export declare const PRESET_TEXT_COLORS: readonly ["#e11d48", "#ea580c", "#ca8a04", "#16a34a", "#0891b2", "#2563eb", "#7c3aed", "#db2777", "#71717a"];
3
+ type ColorMenuProps = {
4
+ label: string;
5
+ active?: boolean;
6
+ currentColor: string | null;
7
+ presets: readonly string[];
8
+ customPresets?: readonly string[];
9
+ onPick: (color: string) => void;
10
+ onRemember?: (color: string) => void;
11
+ onRemoveCustom?: (color: string) => void;
12
+ onClear: () => void;
13
+ children: React.ReactNode;
14
+ };
15
+ /** 工具栏颜色菜单:预设色板 + 任意色 + 清除 */
16
+ export declare function ColorMenu({ label, active, currentColor, presets, customPresets, onPick, onRemember, onRemoveCustom, onClear, children, }: ColorMenuProps): import("react").JSX.Element;
17
+ export {};
18
+ //# sourceMappingURL=ColorMenu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ColorMenu.d.ts","sourceRoot":"","sources":["../../src/editor/ColorMenu.tsx"],"names":[],"mappings":"AAKA,eAAO,MAAM,uBAAuB,8GAU1B,CAAC;AAEX,eAAO,MAAM,kBAAkB,8GAUrB,CAAC;AAEX,KAAK,cAAc,GAAG;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,CAAC;AAUF,8BAA8B;AAC9B,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,MAAM,EACN,YAAY,EACZ,OAAO,EACP,aAAa,EACb,MAAM,EACN,UAAU,EACV,cAAc,EACd,OAAO,EACP,QAAQ,GACT,EAAE,cAAc,+BAgJhB"}
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Eraser, X } from "lucide-react";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ export const PRESET_HIGHLIGHT_COLORS = [
6
+ "#fde047",
7
+ "#fdba74",
8
+ "#fca5a5",
9
+ "#f9a8d4",
10
+ "#d8b4fe",
11
+ "#93c5fd",
12
+ "#67e8f9",
13
+ "#86efac",
14
+ "#d4d4d8",
15
+ ];
16
+ export const PRESET_TEXT_COLORS = [
17
+ "#e11d48",
18
+ "#ea580c",
19
+ "#ca8a04",
20
+ "#16a34a",
21
+ "#0891b2",
22
+ "#2563eb",
23
+ "#7c3aed",
24
+ "#db2777",
25
+ "#71717a",
26
+ ];
27
+ function sameColor(left, right) {
28
+ return left?.toLowerCase() === right.toLowerCase();
29
+ }
30
+ function colorInputValue(color) {
31
+ return color && /^#[0-9a-fA-F]{6}$/i.test(color) ? color : "#000000";
32
+ }
33
+ /** 工具栏颜色菜单:预设色板 + 任意色 + 清除 */
34
+ export function ColorMenu({ label, active, currentColor, presets, customPresets, onPick, onRemember, onRemoveCustom, onClear, children, }) {
35
+ const [open, setOpen] = useState(false);
36
+ const rootRef = useRef(null);
37
+ const pickedRef = useRef(null);
38
+ const customId = useId();
39
+ const rememberPicked = () => {
40
+ if (!pickedRef.current) {
41
+ return;
42
+ }
43
+ onRemember?.(pickedRef.current);
44
+ pickedRef.current = null;
45
+ };
46
+ const close = () => {
47
+ rememberPicked();
48
+ setOpen(false);
49
+ };
50
+ useEffect(() => {
51
+ if (!open) {
52
+ return;
53
+ }
54
+ const onPointerDown = (event) => {
55
+ if (!rootRef.current?.contains(event.target)) {
56
+ close();
57
+ }
58
+ };
59
+ const onKeyDown = (event) => {
60
+ if (event.key === "Escape") {
61
+ close();
62
+ }
63
+ };
64
+ document.addEventListener("pointerdown", onPointerDown);
65
+ document.addEventListener("keydown", onKeyDown);
66
+ return () => {
67
+ document.removeEventListener("pointerdown", onPointerDown);
68
+ document.removeEventListener("keydown", onKeyDown);
69
+ };
70
+ }, [open, onRemember]);
71
+ return (_jsxs("div", { className: "color-menu", ref: rootRef, children: [_jsxs("button", { className: `icon-btn icon-btn--ghost toolbar__btn color-menu__trigger${active ? " toolbar__btn--active" : ""}`, type: "button", title: label, "aria-label": label, "aria-expanded": open, "aria-haspopup": "dialog", "aria-pressed": active, onClick: () => {
72
+ if (open) {
73
+ close();
74
+ return;
75
+ }
76
+ setOpen(true);
77
+ }, children: [children, _jsx("span", { className: "color-menu__bar", style: currentColor ? { background: currentColor } : undefined })] }), open ? (_jsxs("div", { className: "color-menu__panel", role: "dialog", "aria-label": label, children: [_jsx("p", { className: "color-menu__caption", children: "\u9884\u8BBE" }), _jsxs("div", { className: "color-menu__swatches", children: [presets.map((color) => (_jsx("button", { className: `color-menu__swatch${sameColor(currentColor, color) ? " color-menu__swatch--active" : ""}`, type: "button", title: color, "aria-label": color, style: { background: color }, onClick: () => {
78
+ onPick(color);
79
+ close();
80
+ } }, color))), _jsx("button", { className: "color-menu__swatch color-menu__swatch--clear", type: "button", title: "\u6E05\u9664", "aria-label": "\u6E05\u9664", onClick: () => {
81
+ onClear();
82
+ close();
83
+ }, children: _jsx(Eraser, { "aria-hidden": true }) })] }), customPresets && customPresets.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "color-menu__caption color-menu__caption--custom", children: "\u81EA\u5B9A\u4E49" }), _jsx("div", { className: "color-menu__swatches", children: customPresets.map((color) => (_jsxs("span", { className: "color-menu__custom-swatch", children: [_jsx("button", { className: `color-menu__swatch${sameColor(currentColor, color) ? " color-menu__swatch--active" : ""}`, type: "button", title: color, "aria-label": color, style: { background: color }, onClick: () => {
84
+ onPick(color);
85
+ close();
86
+ } }), _jsx("button", { className: "color-menu__swatch-remove", type: "button", title: "\u5220\u9664", "aria-label": `删除 ${color}`, onClick: () => onRemoveCustom?.(color), children: _jsx(X, { "aria-hidden": true }) })] }, color))) })] })) : null, _jsxs("label", { className: "color-menu__custom", htmlFor: customId, children: ["\u4EFB\u610F", _jsx("input", { id: customId, type: "color", value: colorInputValue(currentColor), onChange: (event) => {
87
+ pickedRef.current = event.target.value;
88
+ onPick(event.target.value);
89
+ }, onBlur: rememberPicked })] })] })) : null] }));
90
+ }
@@ -0,0 +1,24 @@
1
+ import { Extension, type Editor, type Range } from "@tiptap/react";
2
+ import { type SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion";
3
+ import { Heading1 } from "lucide-react";
4
+ export type SlashCommandContext = {
5
+ editor: Editor;
6
+ range: Range;
7
+ onInsertImage: () => void;
8
+ onInsertVideo: () => void;
9
+ };
10
+ export type SlashItem = {
11
+ title: string;
12
+ icon: typeof Heading1;
13
+ command: (ctx: SlashCommandContext) => void;
14
+ };
15
+ export declare const SLASH_ITEMS: SlashItem[];
16
+ type SlashMenuProps = SuggestionProps<SlashItem, SlashItem>;
17
+ export type SlashMenuHandle = {
18
+ onKeyDown: (props: SuggestionKeyDownProps) => boolean;
19
+ };
20
+ export declare const SlashMenu: import("react").ForwardRefExoticComponent<SlashMenuProps & import("react").RefAttributes<SlashMenuHandle>>;
21
+ /** 输入 / 唤起块类型菜单 */
22
+ export declare function createSlashCommand(onInsertImage: () => void, onInsertVideo: () => void): Extension<any, any>;
23
+ export {};
24
+ //# sourceMappingURL=SlashMenu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SlashMenu.d.ts","sourceRoot":"","sources":["../../src/editor/SlashMenu.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,KAAK,cAAc,GAAG,eAAe,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;AAE5D,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,OAAO,CAAC;CACvD,CAAC;AAEF,eAAO,MAAM,SAAS,4GA0DrB,CAAC;AAEF,mBAAmB;AACnB,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,IAAI,EAAE,aAAa,EAAE,MAAM,IAAI,uBA+CtF"}
@@ -0,0 +1,179 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Extension, ReactRenderer } from "@tiptap/react";
4
+ import Suggestion from "@tiptap/suggestion";
5
+ import { Code, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, ImageIcon, List, ListOrdered, ListTodo, Minus, Quote, Table, Video, } from "lucide-react";
6
+ import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
7
+ const HEADING_ICONS = {
8
+ 1: Heading1,
9
+ 2: Heading2,
10
+ 3: Heading3,
11
+ 4: Heading4,
12
+ 5: Heading5,
13
+ 6: Heading6,
14
+ };
15
+ function headingItem(level) {
16
+ return {
17
+ title: `标题 ${level}`,
18
+ icon: HEADING_ICONS[level],
19
+ command: ({ editor, range }) => {
20
+ editor.chain().focus().deleteRange(range).setHeading({ level }).run();
21
+ },
22
+ };
23
+ }
24
+ export const SLASH_ITEMS = [
25
+ headingItem(1),
26
+ headingItem(2),
27
+ headingItem(3),
28
+ headingItem(4),
29
+ headingItem(5),
30
+ headingItem(6),
31
+ {
32
+ title: "无序列表",
33
+ icon: List,
34
+ command: ({ editor, range }) => {
35
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
36
+ },
37
+ },
38
+ {
39
+ title: "有序列表",
40
+ icon: ListOrdered,
41
+ command: ({ editor, range }) => {
42
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
43
+ },
44
+ },
45
+ {
46
+ title: "待办",
47
+ icon: ListTodo,
48
+ command: ({ editor, range }) => {
49
+ editor.chain().focus().deleteRange(range).toggleTaskList().run();
50
+ },
51
+ },
52
+ {
53
+ title: "代码块",
54
+ icon: Code,
55
+ command: ({ editor, range }) => {
56
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
57
+ },
58
+ },
59
+ {
60
+ title: "引用",
61
+ icon: Quote,
62
+ command: ({ editor, range }) => {
63
+ editor.chain().focus().deleteRange(range).toggleBlockquote().run();
64
+ },
65
+ },
66
+ {
67
+ title: "表格",
68
+ icon: Table,
69
+ command: ({ editor, range }) => {
70
+ editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
71
+ },
72
+ },
73
+ {
74
+ title: "分割线",
75
+ icon: Minus,
76
+ command: ({ editor, range }) => {
77
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
78
+ },
79
+ },
80
+ {
81
+ title: "图片",
82
+ icon: ImageIcon,
83
+ command: ({ editor, range, onInsertImage }) => {
84
+ editor.chain().focus().deleteRange(range).run();
85
+ onInsertImage();
86
+ },
87
+ },
88
+ {
89
+ title: "视频",
90
+ icon: Video,
91
+ command: ({ editor, range, onInsertVideo }) => {
92
+ editor.chain().focus().deleteRange(range).run();
93
+ onInsertVideo();
94
+ },
95
+ },
96
+ ];
97
+ export const SlashMenu = forwardRef(function SlashMenu({ items, command }, ref) {
98
+ const [selected, setSelected] = useState(0);
99
+ useEffect(() => {
100
+ setSelected(0);
101
+ }, [items]);
102
+ useImperativeHandle(ref, () => ({
103
+ onKeyDown: ({ event }) => {
104
+ if (items.length === 0) {
105
+ return false;
106
+ }
107
+ if (event.key === "ArrowUp") {
108
+ setSelected((index) => (index + items.length - 1) % items.length);
109
+ return true;
110
+ }
111
+ if (event.key === "ArrowDown") {
112
+ setSelected((index) => (index + 1) % items.length);
113
+ return true;
114
+ }
115
+ if (event.key === "Enter") {
116
+ const item = items[selected];
117
+ if (item) {
118
+ command(item);
119
+ }
120
+ return true;
121
+ }
122
+ return false;
123
+ },
124
+ }));
125
+ if (items.length === 0) {
126
+ return null;
127
+ }
128
+ return (_jsx("div", { className: "slash-menu", role: "listbox", children: items.map((item, index) => {
129
+ const Icon = item.icon;
130
+ return (_jsxs("button", { className: `slash-menu__item${index === selected ? " slash-menu__item--active" : ""}`, type: "button", role: "option", "aria-selected": index === selected, onMouseEnter: () => setSelected(index), onClick: () => command(item), children: [_jsx(Icon, { "aria-hidden": true }), _jsx("span", { children: item.title })] }, item.title));
131
+ }) }));
132
+ });
133
+ /** 输入 / 唤起块类型菜单 */
134
+ export function createSlashCommand(onInsertImage, onInsertVideo) {
135
+ return Extension.create({
136
+ name: "slashCommand",
137
+ addProseMirrorPlugins() {
138
+ return [
139
+ Suggestion({
140
+ editor: this.editor,
141
+ char: "/",
142
+ items: ({ query }) => SLASH_ITEMS.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())),
143
+ command: ({ editor, range, props }) => {
144
+ props.command({ editor, range, onInsertImage, onInsertVideo });
145
+ },
146
+ render: () => {
147
+ let component = null;
148
+ let unmount = null;
149
+ return {
150
+ onStart: (props) => {
151
+ component = new ReactRenderer(SlashMenu, {
152
+ editor: props.editor,
153
+ props,
154
+ });
155
+ unmount = props.mount(component.element);
156
+ },
157
+ onUpdate: (props) => {
158
+ component?.updateProps(props);
159
+ },
160
+ onKeyDown: (props) => {
161
+ if (props.event.key === "Escape") {
162
+ unmount?.();
163
+ return true;
164
+ }
165
+ return component?.ref?.onKeyDown(props) ?? false;
166
+ },
167
+ onExit: () => {
168
+ unmount?.();
169
+ component?.destroy();
170
+ unmount = null;
171
+ component = null;
172
+ },
173
+ };
174
+ },
175
+ }),
176
+ ];
177
+ },
178
+ });
179
+ }
@@ -0,0 +1,45 @@
1
+ export declare const FONT_FAMILIES: readonly [{
2
+ readonly label: "宋体";
3
+ readonly value: "SimSun, Songti SC, serif";
4
+ }, {
5
+ readonly label: "黑体";
6
+ readonly value: "SimHei, Heiti SC, sans-serif";
7
+ }, {
8
+ readonly label: "楷体";
9
+ readonly value: "KaiTi, Kaiti SC, serif";
10
+ }, {
11
+ readonly label: "微软雅黑";
12
+ readonly value: "\"Microsoft YaHei\", \"PingFang SC\", sans-serif";
13
+ }, {
14
+ readonly label: "苹方";
15
+ readonly value: "\"PingFang SC\", \"Microsoft YaHei\", sans-serif";
16
+ }, {
17
+ readonly label: "Arial";
18
+ readonly value: "Arial, sans-serif";
19
+ }, {
20
+ readonly label: "Georgia";
21
+ readonly value: "Georgia, serif";
22
+ }, {
23
+ readonly label: "Times New Roman";
24
+ readonly value: "\"Times New Roman\", Times, serif";
25
+ }, {
26
+ readonly label: "Consolas";
27
+ readonly value: "Consolas, monospace";
28
+ }];
29
+ export declare const FONT_SIZES: readonly ["12px", "14px", "16px", "18px", "20px", "24px", "28px", "32px", "36px"];
30
+ export declare const LETTER_SPACINGS: readonly ["-1px", "-0.5px", "0px", "0.5px", "1px", "1.5px", "2px", "3px", "4px", "6px", "8px"];
31
+ export declare const LINE_HEIGHTS: readonly ["1", "1.15", "1.25", "1.5", "1.75", "2", "2.25", "2.5", "2.75", "3"];
32
+ type TextStyleMenuProps = {
33
+ fontFamily: string | null;
34
+ fontSize: string | null;
35
+ letterSpacing: string | null;
36
+ lineHeight: string | null;
37
+ onFontFamily: (value: string | null) => void;
38
+ onFontSize: (value: string | null) => void;
39
+ onLetterSpacing: (value: string | null) => void;
40
+ onLineHeight: (value: string | null) => void;
41
+ };
42
+ /** 工具栏文本样式:字体、字号、字间距、行距 */
43
+ export declare function TextStyleMenu({ fontFamily, fontSize, letterSpacing, lineHeight, onFontFamily, onFontSize, onLetterSpacing, onLineHeight, }: TextStyleMenuProps): import("react").JSX.Element;
44
+ export {};
45
+ //# sourceMappingURL=TextStyleMenu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TextStyleMenu.d.ts","sourceRoot":"","sources":["../../src/editor/TextStyleMenu.tsx"],"names":[],"mappings":"AAKA,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUhB,CAAC;AAEX,eAAO,MAAM,UAAU,mFAAoF,CAAC;AAE5G,eAAO,MAAM,eAAe,gGAYlB,CAAC;AAEX,eAAO,MAAM,YAAY,gFAAiF,CAAC;AAE3G,KAAK,kBAAkB,GAAG;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC7C,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC3C,eAAe,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;CAC9C,CAAC;AAkCF,2BAA2B;AAC3B,wBAAgB,aAAa,CAAC,EAC5B,UAAU,EACV,QAAQ,EACR,aAAa,EACb,UAAU,EACV,YAAY,EACZ,UAAU,EACV,eAAe,EACf,YAAY,GACb,EAAE,kBAAkB,+BA0EpB"}
@@ -0,0 +1,65 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Type } from "lucide-react";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ export const FONT_FAMILIES = [
6
+ { label: "宋体", value: "SimSun, Songti SC, serif" },
7
+ { label: "黑体", value: "SimHei, Heiti SC, sans-serif" },
8
+ { label: "楷体", value: "KaiTi, Kaiti SC, serif" },
9
+ { label: "微软雅黑", value: '"Microsoft YaHei", "PingFang SC", sans-serif' },
10
+ { label: "苹方", value: '"PingFang SC", "Microsoft YaHei", sans-serif' },
11
+ { label: "Arial", value: "Arial, sans-serif" },
12
+ { label: "Georgia", value: "Georgia, serif" },
13
+ { label: "Times New Roman", value: '"Times New Roman", Times, serif' },
14
+ { label: "Consolas", value: "Consolas, monospace" },
15
+ ];
16
+ export const FONT_SIZES = ["12px", "14px", "16px", "18px", "20px", "24px", "28px", "32px", "36px"];
17
+ export const LETTER_SPACINGS = [
18
+ "-1px",
19
+ "-0.5px",
20
+ "0px",
21
+ "0.5px",
22
+ "1px",
23
+ "1.5px",
24
+ "2px",
25
+ "3px",
26
+ "4px",
27
+ "6px",
28
+ "8px",
29
+ ];
30
+ export const LINE_HEIGHTS = ["1", "1.15", "1.25", "1.5", "1.75", "2", "2.25", "2.5", "2.75", "3"];
31
+ function StyleRow({ label, value, options, onChange, }) {
32
+ const id = useId();
33
+ return (_jsxs("label", { className: "text-style-menu__row", htmlFor: id, children: [_jsx("span", { children: label }), _jsxs("select", { id: id, className: "text-style-menu__select", value: value ?? "", onChange: (event) => onChange(event.target.value || null), children: [_jsx("option", { value: "", children: "\u9ED8\u8BA4" }), options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })] }));
34
+ }
35
+ /** 工具栏文本样式:字体、字号、字间距、行距 */
36
+ export function TextStyleMenu({ fontFamily, fontSize, letterSpacing, lineHeight, onFontFamily, onFontSize, onLetterSpacing, onLineHeight, }) {
37
+ const [open, setOpen] = useState(false);
38
+ const rootRef = useRef(null);
39
+ const active = Boolean(fontFamily || fontSize || letterSpacing || lineHeight);
40
+ useEffect(() => {
41
+ if (!open) {
42
+ return;
43
+ }
44
+ const onPointerDown = (event) => {
45
+ if (!rootRef.current?.contains(event.target)) {
46
+ setOpen(false);
47
+ }
48
+ };
49
+ const onKeyDown = (event) => {
50
+ if (event.key === "Escape") {
51
+ setOpen(false);
52
+ }
53
+ };
54
+ document.addEventListener("pointerdown", onPointerDown);
55
+ document.addEventListener("keydown", onKeyDown);
56
+ return () => {
57
+ document.removeEventListener("pointerdown", onPointerDown);
58
+ document.removeEventListener("keydown", onKeyDown);
59
+ };
60
+ }, [open]);
61
+ return (_jsxs("div", { className: "text-style-menu", ref: rootRef, children: [_jsx("button", { className: `icon-btn icon-btn--ghost toolbar__btn${active ? " toolbar__btn--active" : ""}`, type: "button", title: "\u6587\u672C", "aria-label": "\u6587\u672C", "aria-expanded": open, "aria-haspopup": "dialog", "aria-pressed": active, onClick: () => setOpen((value) => !value), children: _jsx(Type, { "aria-hidden": true }) }), open ? (_jsxs("div", { className: "text-style-menu__panel", role: "dialog", "aria-label": "\u6587\u672C", children: [_jsx(StyleRow, { label: "\u5B57\u4F53", value: fontFamily, options: FONT_FAMILIES, onChange: onFontFamily }), _jsx(StyleRow, { label: "\u5B57\u53F7", value: fontSize, options: FONT_SIZES.map((size) => ({ label: size, value: size })), onChange: onFontSize }), _jsx(StyleRow, { label: "\u5B57\u95F4\u8DDD", value: letterSpacing, options: LETTER_SPACINGS.map((spacing) => ({
62
+ label: spacing === "0px" ? "0" : spacing,
63
+ value: spacing,
64
+ })), onChange: onLetterSpacing }), _jsx(StyleRow, { label: "\u884C\u8DDD", value: lineHeight, options: LINE_HEIGHTS.map((height) => ({ label: height, value: height })), onChange: onLineHeight })] })) : null] }));
65
+ }
@@ -0,0 +1,15 @@
1
+ import { Node } from "@tiptap/core";
2
+ import type { VideoProvider } from "./video-url";
3
+ declare module "@tiptap/core" {
4
+ interface Commands<ReturnType> {
5
+ videoEmbed: {
6
+ setVideoEmbed: (attrs: {
7
+ src: string;
8
+ provider: VideoProvider;
9
+ }) => ReturnType;
10
+ };
11
+ }
12
+ }
13
+ /** 块级视频:本地文件用 video,外站用官方播放器 iframe */
14
+ export declare const VideoEmbed: Node<any, any>;
15
+ //# sourceMappingURL=VideoEmbed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoEmbed.d.ts","sourceRoot":"","sources":["../../src/editor/VideoEmbed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,UAAU,EAAE;YACV,aAAa,EAAE,CAAC,KAAK,EAAE;gBAAE,GAAG,EAAE,MAAM,CAAC;gBAAC,QAAQ,EAAE,aAAa,CAAA;aAAE,KAAK,UAAU,CAAC;SAChF,CAAC;KACH;CACF;AAMD,uCAAuC;AACvC,eAAO,MAAM,UAAU,gBA8FrB,CAAC"}
@@ -0,0 +1,95 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { TextSelection } from "@tiptap/pm/state";
3
+ function attrString(value) {
4
+ return typeof value === "string" ? value : null;
5
+ }
6
+ /** 块级视频:本地文件用 video,外站用官方播放器 iframe */
7
+ export const VideoEmbed = Node.create({
8
+ name: "videoEmbed",
9
+ group: "block",
10
+ atom: true,
11
+ draggable: true,
12
+ selectable: true,
13
+ addAttributes() {
14
+ return {
15
+ src: { default: null },
16
+ provider: { default: "file" },
17
+ };
18
+ },
19
+ parseHTML() {
20
+ return [
21
+ {
22
+ tag: "video[src]",
23
+ getAttrs: (element) => {
24
+ if (!(element instanceof HTMLElement)) {
25
+ return false;
26
+ }
27
+ const src = element.getAttribute("src");
28
+ if (!src) {
29
+ return false;
30
+ }
31
+ return { src, provider: "file" };
32
+ },
33
+ },
34
+ {
35
+ tag: "iframe[data-video-provider]",
36
+ getAttrs: (element) => {
37
+ if (!(element instanceof HTMLElement)) {
38
+ return false;
39
+ }
40
+ const provider = element.getAttribute("data-video-provider");
41
+ const src = element.getAttribute("src");
42
+ if ((provider !== "youtube" && provider !== "bilibili") || !src) {
43
+ return false;
44
+ }
45
+ return { src, provider };
46
+ },
47
+ },
48
+ ];
49
+ },
50
+ renderHTML({ node }) {
51
+ const src = attrString(node.attrs.src);
52
+ const provider = node.attrs.provider;
53
+ if (!src) {
54
+ return ["video", { controls: "true", preload: "metadata" }];
55
+ }
56
+ if (provider === "youtube" || provider === "bilibili") {
57
+ return [
58
+ "iframe",
59
+ mergeAttributes({
60
+ src,
61
+ "data-video-provider": provider,
62
+ allowfullscreen: "true",
63
+ frameborder: "0",
64
+ }),
65
+ ];
66
+ }
67
+ return [
68
+ "video",
69
+ mergeAttributes({
70
+ src,
71
+ controls: "true",
72
+ preload: "metadata",
73
+ }),
74
+ ];
75
+ },
76
+ addCommands() {
77
+ return {
78
+ setVideoEmbed: (attrs) => ({ state, dispatch }) => {
79
+ const type = state.schema.nodes.videoEmbed;
80
+ if (!type) {
81
+ return false;
82
+ }
83
+ const node = type.create(attrs);
84
+ const $from = state.doc.resolve(state.selection.from);
85
+ const insertAt = $from.parent.inlineContent ? $from.after() : state.selection.to;
86
+ if (dispatch) {
87
+ const tr = state.tr.insert(insertAt, node);
88
+ const $after = tr.doc.resolve(Math.min(insertAt + node.nodeSize, tr.doc.content.size));
89
+ dispatch(tr.setSelection(TextSelection.near($after)).scrollIntoView());
90
+ }
91
+ return true;
92
+ },
93
+ };
94
+ },
95
+ });
@@ -0,0 +1,12 @@
1
+ type VideoMenuProps = {
2
+ open: boolean;
3
+ uploading: boolean;
4
+ error: string | null;
5
+ onOpenChange: (open: boolean) => void;
6
+ onUpload: () => void;
7
+ onSubmitUrl: (url: string) => void;
8
+ };
9
+ /** 工具栏视频菜单:上传文件或粘贴链接 */
10
+ export declare function VideoMenu({ open, uploading, error, onOpenChange, onUpload, onSubmitUrl, }: VideoMenuProps): import("react").JSX.Element;
11
+ export {};
12
+ //# sourceMappingURL=VideoMenu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoMenu.d.ts","sourceRoot":"","sources":["../../src/editor/VideoMenu.tsx"],"names":[],"mappings":"AAKA,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACtC,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC,CAAC;AAEF,wBAAwB;AACxB,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,SAAS,EACT,KAAK,EACL,YAAY,EACZ,QAAQ,EACR,WAAW,GACZ,EAAE,cAAc,+BAyEhB"}