@teriusu/rich-editor 0.4.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 (90) hide show
  1. package/README.md +178 -0
  2. package/dist/components/BubbleToolbar.svelte +299 -0
  3. package/dist/components/BubbleToolbar.svelte.d.ts +9 -0
  4. package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -0
  5. package/dist/components/FixedToolbar.svelte +892 -0
  6. package/dist/components/FixedToolbar.svelte.d.ts +11 -0
  7. package/dist/components/FixedToolbar.svelte.d.ts.map +1 -0
  8. package/dist/components/InputModal.svelte +102 -0
  9. package/dist/components/InputModal.svelte.d.ts +11 -0
  10. package/dist/components/InputModal.svelte.d.ts.map +1 -0
  11. package/dist/components/PdfViewer.svelte +152 -0
  12. package/dist/components/PdfViewer.svelte.d.ts +8 -0
  13. package/dist/components/PdfViewer.svelte.d.ts.map +1 -0
  14. package/dist/components/SlashCommandMenu.svelte +315 -0
  15. package/dist/components/SlashCommandMenu.svelte.d.ts +14 -0
  16. package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -0
  17. package/dist/components/TableBubbleMenu.svelte +472 -0
  18. package/dist/components/TableBubbleMenu.svelte.d.ts +8 -0
  19. package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -0
  20. package/dist/components/TipTapEditor.svelte +649 -0
  21. package/dist/components/TipTapEditor.svelte.d.ts +17 -0
  22. package/dist/components/TipTapEditor.svelte.d.ts.map +1 -0
  23. package/dist/extensions/Column.d.ts +3 -0
  24. package/dist/extensions/Column.d.ts.map +1 -0
  25. package/dist/extensions/Column.js +20 -0
  26. package/dist/extensions/Columns.d.ts +11 -0
  27. package/dist/extensions/Columns.d.ts.map +1 -0
  28. package/dist/extensions/Columns.js +103 -0
  29. package/dist/extensions/Columns.test.d.ts +2 -0
  30. package/dist/extensions/Columns.test.d.ts.map +1 -0
  31. package/dist/extensions/Columns.test.js +62 -0
  32. package/dist/extensions/FileAttachment.d.ts +24 -0
  33. package/dist/extensions/FileAttachment.d.ts.map +1 -0
  34. package/dist/extensions/FileAttachment.js +190 -0
  35. package/dist/extensions/FileAttachment.test.d.ts +2 -0
  36. package/dist/extensions/FileAttachment.test.d.ts.map +1 -0
  37. package/dist/extensions/FileAttachment.test.js +63 -0
  38. package/dist/extensions/FixedDetails.d.ts +2 -0
  39. package/dist/extensions/FixedDetails.d.ts.map +1 -0
  40. package/dist/extensions/FixedDetails.js +100 -0
  41. package/dist/extensions/FixedDetails.test.d.ts +2 -0
  42. package/dist/extensions/FixedDetails.test.d.ts.map +1 -0
  43. package/dist/extensions/FixedDetails.test.js +40 -0
  44. package/dist/extensions/Indent.d.ts +11 -0
  45. package/dist/extensions/Indent.d.ts.map +1 -0
  46. package/dist/extensions/Indent.js +87 -0
  47. package/dist/extensions/Indent.test.d.ts +2 -0
  48. package/dist/extensions/Indent.test.d.ts.map +1 -0
  49. package/dist/extensions/Indent.test.js +78 -0
  50. package/dist/extensions/PdfBlock.d.ts +3 -0
  51. package/dist/extensions/PdfBlock.d.ts.map +1 -0
  52. package/dist/extensions/PdfBlock.js +301 -0
  53. package/dist/extensions/PdfBlock.test.d.ts +2 -0
  54. package/dist/extensions/PdfBlock.test.d.ts.map +1 -0
  55. package/dist/extensions/PdfBlock.test.js +51 -0
  56. package/dist/extensions/VideoBlock.d.ts +16 -0
  57. package/dist/extensions/VideoBlock.d.ts.map +1 -0
  58. package/dist/extensions/VideoBlock.js +87 -0
  59. package/dist/extensions/VideoBlock.test.d.ts +2 -0
  60. package/dist/extensions/VideoBlock.test.d.ts.map +1 -0
  61. package/dist/extensions/VideoBlock.test.js +54 -0
  62. package/dist/index.d.ts +20 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +20 -0
  65. package/dist/styles/editor.css +698 -0
  66. package/dist/types.d.ts +44 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +1 -0
  69. package/dist/utils/cn.d.ts +3 -0
  70. package/dist/utils/cn.d.ts.map +1 -0
  71. package/dist/utils/cn.js +5 -0
  72. package/dist/utils/cn.test.d.ts +2 -0
  73. package/dist/utils/cn.test.d.ts.map +1 -0
  74. package/dist/utils/cn.test.js +28 -0
  75. package/dist/utils/escape-html.d.ts +2 -0
  76. package/dist/utils/escape-html.d.ts.map +1 -0
  77. package/dist/utils/escape-html.js +7 -0
  78. package/dist/utils/escape-html.test.d.ts +2 -0
  79. package/dist/utils/escape-html.test.d.ts.map +1 -0
  80. package/dist/utils/escape-html.test.js +25 -0
  81. package/dist/utils/pdf.d.ts +9 -0
  82. package/dist/utils/pdf.d.ts.map +1 -0
  83. package/dist/utils/pdf.js +33 -0
  84. package/dist/utils/sanitize.d.ts +9 -0
  85. package/dist/utils/sanitize.d.ts.map +1 -0
  86. package/dist/utils/sanitize.js +115 -0
  87. package/dist/utils/sanitize.test.d.ts +2 -0
  88. package/dist/utils/sanitize.test.d.ts.map +1 -0
  89. package/dist/utils/sanitize.test.js +206 -0
  90. package/package.json +76 -0
@@ -0,0 +1,11 @@
1
+ import { Node as TiptapNode } from "@tiptap/core";
2
+ declare module "@tiptap/core" {
3
+ interface Commands<ReturnType> {
4
+ columns: {
5
+ setColumns: (count?: number) => ReturnType;
6
+ unsetColumns: () => ReturnType;
7
+ };
8
+ }
9
+ }
10
+ export declare const Columns: TiptapNode<any, any>;
11
+ //# sourceMappingURL=Columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Columns.d.ts","sourceRoot":"","sources":["../../src/extensions/Columns.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,UAAU,EAAmB,MAAM,cAAc,CAAC;AAEnE,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,OAAO,EAAE;YACP,UAAU,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,UAAU,CAAC;YAC3C,YAAY,EAAE,MAAM,UAAU,CAAC;SAChC,CAAC;KACH;CACF;AAaD,eAAO,MAAM,OAAO,sBAmGlB,CAAC"}
@@ -0,0 +1,103 @@
1
+ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
2
+ function createColumnContent(count) {
3
+ const columns = [];
4
+ for (let i = 0; i < count; i++) {
5
+ columns.push({
6
+ type: "column",
7
+ content: [{ type: "paragraph" }],
8
+ });
9
+ }
10
+ return columns;
11
+ }
12
+ export const Columns = TiptapNode.create({
13
+ name: "columns",
14
+ group: "block",
15
+ content: "column{2,3}",
16
+ defining: true,
17
+ addAttributes() {
18
+ return {
19
+ columns: {
20
+ default: 2,
21
+ parseHTML: (element) => {
22
+ const attr = element.getAttribute("data-columns");
23
+ if (attr)
24
+ return parseInt(attr, 10) || 2;
25
+ // Legacy: count child columns
26
+ return element.children.length || 2;
27
+ },
28
+ renderHTML: (attributes) => ({
29
+ "data-columns": attributes.columns,
30
+ }),
31
+ },
32
+ };
33
+ },
34
+ parseHTML() {
35
+ return [
36
+ { tag: 'div[data-type="columns"]' },
37
+ { tag: "div.tiptap-columns" },
38
+ ];
39
+ },
40
+ renderHTML({ HTMLAttributes }) {
41
+ return [
42
+ "div",
43
+ mergeAttributes(HTMLAttributes, { "data-type": "columns" }),
44
+ 0,
45
+ ];
46
+ },
47
+ addCommands() {
48
+ return {
49
+ setColumns: (count = 2) => ({ chain }) => {
50
+ return chain()
51
+ .insertContent({
52
+ type: this.name,
53
+ attrs: { columns: count },
54
+ content: createColumnContent(count),
55
+ })
56
+ .run();
57
+ },
58
+ unsetColumns: () => ({ chain, state }) => {
59
+ const { from } = state.selection;
60
+ const node = state.doc.resolve(from);
61
+ for (let d = node.depth; d > 0; d--) {
62
+ if (node.node(d).type.name === this.name) {
63
+ const pos = node.before(d);
64
+ const end = pos + node.node(d).nodeSize;
65
+ // Collect all text content from columns
66
+ const texts = [];
67
+ node.node(d).descendants((child) => {
68
+ if (child.isTextblock && child.textContent) {
69
+ texts.push(child.textContent);
70
+ }
71
+ });
72
+ return chain()
73
+ .deleteRange({ from: pos, to: end })
74
+ .insertContentAt(pos, texts.map((t) => `<p>${t}</p>`).join(""))
75
+ .run();
76
+ }
77
+ }
78
+ return false;
79
+ },
80
+ };
81
+ },
82
+ addKeyboardShortcuts() {
83
+ return {
84
+ // Tab in last column position should exit columns
85
+ "Mod-Enter": () => {
86
+ const { state } = this.editor;
87
+ const { from } = state.selection;
88
+ const resolved = state.doc.resolve(from);
89
+ for (let d = resolved.depth; d > 0; d--) {
90
+ if (resolved.node(d).type.name === this.name) {
91
+ const after = resolved.after(d);
92
+ return this.editor
93
+ .chain()
94
+ .insertContentAt(after, { type: "paragraph" })
95
+ .focus(after + 1)
96
+ .run();
97
+ }
98
+ }
99
+ return false;
100
+ },
101
+ };
102
+ },
103
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=Columns.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Columns.test.d.ts","sourceRoot":"","sources":["../../src/extensions/Columns.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Editor } from '@tiptap/core';
3
+ import StarterKit from '@tiptap/starter-kit';
4
+ import { Columns } from './Columns';
5
+ import { Column } from './Column';
6
+ describe('Columns extension', () => {
7
+ let editor;
8
+ function createEditor(content = '<p></p>') {
9
+ editor = new Editor({
10
+ element: document.createElement('div'),
11
+ extensions: [StarterKit, Columns, Column],
12
+ content
13
+ });
14
+ return editor;
15
+ }
16
+ afterEach(() => {
17
+ editor?.destroy();
18
+ });
19
+ it('Columns extension name is "columns"', () => {
20
+ createEditor();
21
+ const ext = editor.extensionManager.extensions.find((e) => e.name === 'columns');
22
+ expect(ext).toBeDefined();
23
+ });
24
+ it('Column extension name is "column"', () => {
25
+ createEditor();
26
+ const ext = editor.extensionManager.extensions.find((e) => e.name === 'column');
27
+ expect(ext).toBeDefined();
28
+ });
29
+ it('creates 2-column layout via command', () => {
30
+ createEditor();
31
+ editor.commands.setColumns(2);
32
+ const doc = editor.getJSON();
33
+ const columnsNode = doc.content?.find((n) => n.type === 'columns');
34
+ expect(columnsNode).toBeDefined();
35
+ expect(columnsNode?.attrs?.columns).toBe(2);
36
+ expect(columnsNode?.content).toHaveLength(2);
37
+ expect(columnsNode?.content?.[0]?.type).toBe('column');
38
+ });
39
+ it('creates 3-column layout via command', () => {
40
+ createEditor();
41
+ editor.commands.setColumns(3);
42
+ const doc = editor.getJSON();
43
+ const columnsNode = doc.content?.find((n) => n.type === 'columns');
44
+ expect(columnsNode).toBeDefined();
45
+ expect(columnsNode?.attrs?.columns).toBe(3);
46
+ expect(columnsNode?.content).toHaveLength(3);
47
+ });
48
+ it('parses <div data-type="columns"> HTML', () => {
49
+ createEditor('<div data-type="columns"><div data-type="column"><p>A</p></div><div data-type="column"><p>B</p></div></div>');
50
+ const doc = editor.getJSON();
51
+ const columnsNode = doc.content?.find((n) => n.type === 'columns');
52
+ expect(columnsNode).toBeDefined();
53
+ expect(columnsNode?.content).toHaveLength(2);
54
+ });
55
+ it('renders HTML with data-type="columns"', () => {
56
+ createEditor();
57
+ editor.commands.setColumns(2);
58
+ const html = editor.getHTML();
59
+ expect(html).toContain('data-type="columns"');
60
+ expect(html).toContain('data-type="column"');
61
+ });
62
+ });
@@ -0,0 +1,24 @@
1
+ import { Node } from "@tiptap/core";
2
+ export interface FileAttachmentOptions {
3
+ HTMLAttributes: Record<string, unknown>;
4
+ }
5
+ export type FileResolveResult = {
6
+ src: string;
7
+ name?: string;
8
+ size?: number;
9
+ };
10
+ export type FileResolver = (fileId: string) => Promise<FileResolveResult>;
11
+ declare module "@tiptap/core" {
12
+ interface Commands<ReturnType> {
13
+ fileAttachment: {
14
+ setFileAttachment: (attrs: {
15
+ src?: string;
16
+ fileId?: string;
17
+ name: string;
18
+ size?: number;
19
+ }) => ReturnType;
20
+ };
21
+ }
22
+ }
23
+ export declare const FileAttachment: Node<FileAttachmentOptions, any>;
24
+ //# sourceMappingURL=FileAttachment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FileAttachment.d.ts","sourceRoot":"","sources":["../../src/extensions/FileAttachment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAErD,MAAM,WAAW,qBAAqB;IACpC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC;AAED,MAAM,MAAM,iBAAiB,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAC9E,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAE1E,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,cAAc,EAAE;YACd,iBAAiB,EAAE,CAAC,KAAK,EAAE;gBACzB,GAAG,CAAC,EAAE,MAAM,CAAC;gBACb,MAAM,CAAC,EAAE,MAAM,CAAC;gBAChB,IAAI,EAAE,MAAM,CAAC;gBACb,IAAI,CAAC,EAAE,MAAM,CAAC;aACf,KAAK,UAAU,CAAC;SAClB,CAAC;KACH;CACF;AA6BD,eAAO,MAAM,cAAc,kCA6KzB,CAAC"}
@@ -0,0 +1,190 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ function formatFileSize(bytes) {
3
+ if (bytes < 1024)
4
+ return `${bytes} B`;
5
+ if (bytes < 1024 * 1024)
6
+ return `${(bytes / 1024).toFixed(1)} KB`;
7
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
8
+ }
9
+ function getFileIcon(name) {
10
+ const ext = name.split(".").pop()?.toLowerCase() || "";
11
+ const icons = {
12
+ pdf: "\u{1F4C4}",
13
+ doc: "\u{1F4DD}",
14
+ docx: "\u{1F4DD}",
15
+ xls: "\u{1F4CA}",
16
+ xlsx: "\u{1F4CA}",
17
+ ppt: "\u{1F4CA}",
18
+ pptx: "\u{1F4CA}",
19
+ zip: "\u{1F4E6}",
20
+ rar: "\u{1F4E6}",
21
+ "7z": "\u{1F4E6}",
22
+ txt: "\u{1F4C3}",
23
+ csv: "\u{1F4C3}",
24
+ hwp: "\u{1F4C4}",
25
+ hwpx: "\u{1F4C4}",
26
+ };
27
+ return icons[ext] || "\u{1F4CE}";
28
+ }
29
+ export const FileAttachment = Node.create({
30
+ name: "fileAttachment",
31
+ group: "block",
32
+ atom: true,
33
+ draggable: true,
34
+ addOptions() {
35
+ return { HTMLAttributes: {} };
36
+ },
37
+ addAttributes() {
38
+ return {
39
+ src: { default: null },
40
+ fileId: { default: null },
41
+ name: { default: "\uD30C\uC77C" },
42
+ size: { default: null },
43
+ };
44
+ },
45
+ parseHTML() {
46
+ return [
47
+ // 하이브리드: data-file-id + data-file-src
48
+ {
49
+ tag: "div[data-file-id]",
50
+ getAttrs: (dom) => {
51
+ const el = dom;
52
+ return {
53
+ fileId: el.getAttribute("data-file-id"),
54
+ src: el.getAttribute("data-file-src") || null,
55
+ name: el.getAttribute("data-file-name") || "\uD30C\uC77C",
56
+ size: el.getAttribute("data-file-size")
57
+ ? Number(el.getAttribute("data-file-size"))
58
+ : null,
59
+ };
60
+ },
61
+ },
62
+ // URL 직접 방식 (data-file-src만)
63
+ {
64
+ tag: "div[data-file-src]",
65
+ getAttrs: (dom) => {
66
+ const el = dom;
67
+ return {
68
+ src: el.getAttribute("data-file-src"),
69
+ fileId: null,
70
+ name: el.getAttribute("data-file-name") || "\uD30C\uC77C",
71
+ size: el.getAttribute("data-file-size")
72
+ ? Number(el.getAttribute("data-file-size"))
73
+ : null,
74
+ };
75
+ },
76
+ },
77
+ // 레거시: <tiptap-file id="X">
78
+ {
79
+ tag: "tiptap-file",
80
+ getAttrs: (dom) => {
81
+ const el = dom;
82
+ return {
83
+ fileId: el.getAttribute("id") || null,
84
+ src: null,
85
+ name: el.textContent?.trim() || "\uD30C\uC77C",
86
+ size: null,
87
+ };
88
+ },
89
+ },
90
+ ];
91
+ },
92
+ renderHTML({ HTMLAttributes }) {
93
+ const attrs = {};
94
+ if (HTMLAttributes.fileId)
95
+ attrs["data-file-id"] = HTMLAttributes.fileId;
96
+ if (HTMLAttributes.src)
97
+ attrs["data-file-src"] = HTMLAttributes.src;
98
+ attrs["data-file-name"] = HTMLAttributes.name || "\uD30C\uC77C";
99
+ if (HTMLAttributes.size)
100
+ attrs["data-file-size"] = String(HTMLAttributes.size);
101
+ return ["div", mergeAttributes(this.options.HTMLAttributes, attrs)];
102
+ },
103
+ addNodeView() {
104
+ return ({ node, editor }) => {
105
+ const dom = document.createElement("div");
106
+ dom.style.cssText =
107
+ "display:flex;align-items:center;gap:10px;padding:10px 14px;margin:8px 0;border:1px solid var(--border, #e2e8f0);border-radius:10px;background:var(--muted, #f7fafc);cursor:default;max-width:400px;";
108
+ const icon = document.createElement("span");
109
+ icon.style.cssText = "font-size:24px;flex-shrink:0;line-height:1;";
110
+ icon.textContent = getFileIcon(node.attrs.name);
111
+ const info = document.createElement("div");
112
+ info.style.cssText = "flex:1;min-width:0;";
113
+ const nameEl = document.createElement("a");
114
+ nameEl.target = "_blank";
115
+ nameEl.rel = "noopener noreferrer";
116
+ nameEl.textContent = node.attrs.name;
117
+ nameEl.style.cssText =
118
+ "display:block;font-size:13px;font-weight:600;color:var(--primary, #4A7DAC);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
119
+ const sizeEl = document.createElement("span");
120
+ sizeEl.style.cssText =
121
+ "font-size:11px;color:var(--muted-foreground, #718096);";
122
+ // URL이 있으면 바로 표시
123
+ if (node.attrs.src) {
124
+ nameEl.href = node.attrs.src;
125
+ if (node.attrs.size)
126
+ sizeEl.textContent = formatFileSize(node.attrs.size);
127
+ }
128
+ else if (node.attrs.fileId) {
129
+ // ID만 있으면 resolver로 해결 시도
130
+ nameEl.href = "#";
131
+ nameEl.style.pointerEvents = "none";
132
+ sizeEl.textContent = "loading...";
133
+ const resolver = editor.storage.fileAttachment?.resolver;
134
+ if (resolver) {
135
+ resolver(node.attrs.fileId)
136
+ .then((result) => {
137
+ nameEl.href = result.src;
138
+ nameEl.style.pointerEvents = "";
139
+ if (result.name) {
140
+ nameEl.textContent = result.name;
141
+ icon.textContent = getFileIcon(result.name);
142
+ }
143
+ sizeEl.textContent = result.size ? formatFileSize(result.size) : "";
144
+ })
145
+ .catch(() => {
146
+ sizeEl.textContent = "resolve failed";
147
+ });
148
+ }
149
+ else {
150
+ sizeEl.textContent = `ID: ${node.attrs.fileId}`;
151
+ }
152
+ }
153
+ info.appendChild(nameEl);
154
+ info.appendChild(sizeEl);
155
+ dom.appendChild(icon);
156
+ dom.appendChild(info);
157
+ if (editor.isEditable) {
158
+ const del = document.createElement("button");
159
+ del.type = "button";
160
+ del.textContent = "\u00D7";
161
+ del.style.cssText =
162
+ "flex-shrink:0;width:22px;height:22px;border:none;background:transparent;color:var(--muted-foreground, #718096);font-size:16px;cursor:pointer;border-radius:4px;display:flex;align-items:center;justify-content:center;";
163
+ del.addEventListener("mouseenter", () => {
164
+ del.style.background = "var(--destructive, #FF6B6B)";
165
+ del.style.color = "#fff";
166
+ });
167
+ del.addEventListener("mouseleave", () => {
168
+ del.style.background = "transparent";
169
+ del.style.color = "var(--muted-foreground, #718096)";
170
+ });
171
+ del.addEventListener("click", () => {
172
+ const pos = editor.view.posAtDOM(dom, 0);
173
+ editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
174
+ });
175
+ dom.appendChild(del);
176
+ }
177
+ return { dom };
178
+ };
179
+ },
180
+ addStorage() {
181
+ return { resolver: null };
182
+ },
183
+ addCommands() {
184
+ return {
185
+ setFileAttachment: (attrs) => ({ chain }) => {
186
+ return chain().insertContent({ type: this.name, attrs }).run();
187
+ },
188
+ };
189
+ },
190
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=FileAttachment.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FileAttachment.test.d.ts","sourceRoot":"","sources":["../../src/extensions/FileAttachment.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Editor } from '@tiptap/core';
3
+ import StarterKit from '@tiptap/starter-kit';
4
+ import { FileAttachment } from './FileAttachment';
5
+ describe('FileAttachment extension', () => {
6
+ let editor;
7
+ function createEditor(content = '<p></p>') {
8
+ editor = new Editor({
9
+ element: document.createElement('div'),
10
+ extensions: [StarterKit, FileAttachment],
11
+ content
12
+ });
13
+ return editor;
14
+ }
15
+ afterEach(() => {
16
+ editor?.destroy();
17
+ });
18
+ it('registers with name "fileAttachment"', () => {
19
+ createEditor();
20
+ const ext = editor.extensionManager.extensions.find((e) => e.name === 'fileAttachment');
21
+ expect(ext).toBeDefined();
22
+ });
23
+ it('parses <div data-file-id="123">', () => {
24
+ createEditor('<div data-file-id="123" data-file-name="test.pdf"></div>');
25
+ const doc = editor.getJSON();
26
+ const fileNode = doc.content?.find((n) => n.type === 'fileAttachment');
27
+ expect(fileNode).toBeDefined();
28
+ expect(fileNode?.attrs?.fileId).toBe('123');
29
+ expect(fileNode?.attrs?.name).toBe('test.pdf');
30
+ });
31
+ it('parses <div data-file-src="url">', () => {
32
+ createEditor('<div data-file-src="https://example.com/file.zip" data-file-name="file.zip"></div>');
33
+ const doc = editor.getJSON();
34
+ const fileNode = doc.content?.find((n) => n.type === 'fileAttachment');
35
+ expect(fileNode).toBeDefined();
36
+ expect(fileNode?.attrs?.src).toBe('https://example.com/file.zip');
37
+ expect(fileNode?.attrs?.name).toBe('file.zip');
38
+ });
39
+ it('renders HTML with correct attributes', () => {
40
+ createEditor();
41
+ editor.commands.setFileAttachment({
42
+ src: 'https://example.com/file.zip',
43
+ name: 'file.zip',
44
+ size: 1024
45
+ });
46
+ const html = editor.getHTML();
47
+ expect(html).toContain('data-file-src="https://example.com/file.zip"');
48
+ expect(html).toContain('data-file-name="file.zip"');
49
+ expect(html).toContain('data-file-size="1024"');
50
+ });
51
+ it('uses default name when name is missing', () => {
52
+ createEditor('<div data-file-id="456"></div>');
53
+ const doc = editor.getJSON();
54
+ const fileNode = doc.content?.find((n) => n.type === 'fileAttachment');
55
+ expect(fileNode?.attrs?.name).toBe('파일');
56
+ });
57
+ it('parses file size as number', () => {
58
+ createEditor('<div data-file-id="789" data-file-size="2048"></div>');
59
+ const doc = editor.getJSON();
60
+ const fileNode = doc.content?.find((n) => n.type === 'fileAttachment');
61
+ expect(fileNode?.attrs?.size).toBe(2048);
62
+ });
63
+ });
@@ -0,0 +1,2 @@
1
+ export declare const FixedDetails: import("@tiptap/core").Node<import("@tiptap/extension-details").DetailsOptions, any>;
2
+ //# sourceMappingURL=FixedDetails.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FixedDetails.d.ts","sourceRoot":"","sources":["../../src/extensions/FixedDetails.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,YAAY,sFA+FvB,CAAC"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Details 확장의 토글 버그 수정 래퍼.
3
+ *
4
+ * 원본 문제:
5
+ * 1. persist:false → focus() 호출로 노드 뷰 재생성 → is-open 클래스 소실
6
+ * 2. persist:true → getPos()===0일 때 !pos가 true → 상태 저장 실패
7
+ *
8
+ * 해결: addNodeView를 오버라이드하여 토글 로직을 직접 구현.
9
+ */
10
+ import { Details } from "@tiptap/extension-details";
11
+ import { mergeAttributes } from "@tiptap/core";
12
+ export const FixedDetails = Details.extend({
13
+ addOptions() {
14
+ return {
15
+ ...this.parent?.(),
16
+ persist: false,
17
+ openClassName: "is-open",
18
+ };
19
+ },
20
+ addNodeView() {
21
+ return ({ editor, getPos, node: initialNode, HTMLAttributes }) => {
22
+ let currentNode = initialNode;
23
+ const dom = document.createElement("div");
24
+ const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
25
+ "data-type": this.name,
26
+ });
27
+ Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value));
28
+ // 토글 버튼
29
+ const toggle = document.createElement("button");
30
+ toggle.type = "button";
31
+ toggle.setAttribute("aria-label", "Expand details content");
32
+ dom.append(toggle);
33
+ // 콘텐츠 래퍼
34
+ const content = document.createElement("div");
35
+ dom.append(content);
36
+ let isOpen = Boolean(initialNode.attrs.open);
37
+ const applyState = () => {
38
+ if (isOpen) {
39
+ dom.classList.add(this.options.openClassName);
40
+ toggle.setAttribute("aria-label", "Collapse details content");
41
+ }
42
+ else {
43
+ dom.classList.remove(this.options.openClassName);
44
+ toggle.setAttribute("aria-label", "Expand details content");
45
+ }
46
+ const detailsContent = content.querySelector(':scope > div[data-type="detailsContent"]');
47
+ if (detailsContent) {
48
+ if (isOpen) {
49
+ detailsContent.removeAttribute("hidden");
50
+ }
51
+ else {
52
+ detailsContent.setAttribute("hidden", "hidden");
53
+ }
54
+ }
55
+ };
56
+ // 초기 상태 즉시 적용 (클래스는 바로 설정, detailsContent는 아직 없을 수 있으므로 update에서도 처리)
57
+ applyState();
58
+ toggle.addEventListener("click", (e) => {
59
+ e.preventDefault();
60
+ isOpen = !isOpen;
61
+ applyState();
62
+ // focus()를 호출하지 않아 노드 뷰 재생성을 방지
63
+ });
64
+ return {
65
+ dom,
66
+ contentDOM: content,
67
+ ignoreMutation(mutation) {
68
+ if (mutation.type === "selection")
69
+ return false;
70
+ // dom 자체(클래스 변경)와 dom 내부 비-콘텐츠 영역(hidden 토글 등)의
71
+ // 변경을 무시해야 ProseMirror가 노드 뷰를 재생성하지 않음.
72
+ // contentDOM(content) 내부의 ProseMirror 관리 영역만 통과시킴.
73
+ if (!dom.contains(mutation.target))
74
+ return true;
75
+ if (dom === mutation.target)
76
+ return true;
77
+ if (toggle.contains(mutation.target) || toggle === mutation.target)
78
+ return true;
79
+ // detailsContent의 hidden 속성 변경 무시
80
+ if (mutation.type === "attributes" &&
81
+ mutation.attributeName === "hidden")
82
+ return true;
83
+ return false;
84
+ },
85
+ update: (updatedNode) => {
86
+ if (updatedNode.type !== this.type)
87
+ return false;
88
+ const attrsChanged = updatedNode.attrs.open !== currentNode.attrs.open;
89
+ currentNode = updatedNode;
90
+ if (attrsChanged) {
91
+ isOpen = Boolean(updatedNode.attrs.open);
92
+ }
93
+ // 항상 applyState 호출 — detailsContent가 나중에 렌더링될 수 있으므로
94
+ applyState();
95
+ return true;
96
+ },
97
+ };
98
+ };
99
+ },
100
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=FixedDetails.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FixedDetails.test.d.ts","sourceRoot":"","sources":["../../src/extensions/FixedDetails.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Editor } from '@tiptap/core';
3
+ import StarterKit from '@tiptap/starter-kit';
4
+ import { FixedDetails } from './FixedDetails';
5
+ import DetailsSummary from '@tiptap/extension-details-summary';
6
+ import DetailsContent from '@tiptap/extension-details-content';
7
+ describe('FixedDetails extension', () => {
8
+ let editor;
9
+ function createEditor(content = '<p></p>') {
10
+ editor = new Editor({
11
+ element: document.createElement('div'),
12
+ extensions: [StarterKit, FixedDetails, DetailsSummary, DetailsContent],
13
+ content
14
+ });
15
+ return editor;
16
+ }
17
+ afterEach(() => {
18
+ editor?.destroy();
19
+ });
20
+ it('registers with name "details"', () => {
21
+ createEditor();
22
+ const ext = editor.extensionManager.extensions.find((e) => e.name === 'details');
23
+ expect(ext).toBeDefined();
24
+ });
25
+ it('parses <details><summary> HTML', () => {
26
+ createEditor('<details><summary>Title</summary><div>Content here</div></details>');
27
+ const doc = editor.getJSON();
28
+ const detailsNode = doc.content?.find((n) => n.type === 'details');
29
+ expect(detailsNode).toBeDefined();
30
+ });
31
+ it('renders HTML containing details structure', () => {
32
+ createEditor('<details><summary>Title</summary><div>Content</div></details>');
33
+ const html = editor.getHTML();
34
+ // FixedDetails uses addNodeView which outputs <details> tag,
35
+ // but getHTML() uses renderHTML from the parent Details extension
36
+ // which outputs native <details> element
37
+ expect(html).toContain('<details>');
38
+ expect(html).toContain('<summary>');
39
+ });
40
+ });
@@ -0,0 +1,11 @@
1
+ import { Extension } from "@tiptap/core";
2
+ declare module "@tiptap/core" {
3
+ interface Commands<ReturnType> {
4
+ indent: {
5
+ indent: () => ReturnType;
6
+ outdent: () => ReturnType;
7
+ };
8
+ }
9
+ }
10
+ export declare const Indent: Extension<any, any>;
11
+ //# sourceMappingURL=Indent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Indent.d.ts","sourceRoot":"","sources":["../../src/extensions/Indent.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,MAAM,EAAE;YACN,MAAM,EAAE,MAAM,UAAU,CAAC;YACzB,OAAO,EAAE,MAAM,UAAU,CAAC;SAC3B,CAAC;KACH;CACF;AAKD,eAAO,MAAM,MAAM,qBAiFjB,CAAC"}