@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.
- package/README.md +178 -0
- package/dist/components/BubbleToolbar.svelte +299 -0
- package/dist/components/BubbleToolbar.svelte.d.ts +9 -0
- package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -0
- package/dist/components/FixedToolbar.svelte +892 -0
- package/dist/components/FixedToolbar.svelte.d.ts +11 -0
- package/dist/components/FixedToolbar.svelte.d.ts.map +1 -0
- package/dist/components/InputModal.svelte +102 -0
- package/dist/components/InputModal.svelte.d.ts +11 -0
- package/dist/components/InputModal.svelte.d.ts.map +1 -0
- package/dist/components/PdfViewer.svelte +152 -0
- package/dist/components/PdfViewer.svelte.d.ts +8 -0
- package/dist/components/PdfViewer.svelte.d.ts.map +1 -0
- package/dist/components/SlashCommandMenu.svelte +315 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts +14 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -0
- package/dist/components/TableBubbleMenu.svelte +472 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts +8 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -0
- package/dist/components/TipTapEditor.svelte +649 -0
- package/dist/components/TipTapEditor.svelte.d.ts +17 -0
- package/dist/components/TipTapEditor.svelte.d.ts.map +1 -0
- package/dist/extensions/Column.d.ts +3 -0
- package/dist/extensions/Column.d.ts.map +1 -0
- package/dist/extensions/Column.js +20 -0
- package/dist/extensions/Columns.d.ts +11 -0
- package/dist/extensions/Columns.d.ts.map +1 -0
- package/dist/extensions/Columns.js +103 -0
- package/dist/extensions/Columns.test.d.ts +2 -0
- package/dist/extensions/Columns.test.d.ts.map +1 -0
- package/dist/extensions/Columns.test.js +62 -0
- package/dist/extensions/FileAttachment.d.ts +24 -0
- package/dist/extensions/FileAttachment.d.ts.map +1 -0
- package/dist/extensions/FileAttachment.js +190 -0
- package/dist/extensions/FileAttachment.test.d.ts +2 -0
- package/dist/extensions/FileAttachment.test.d.ts.map +1 -0
- package/dist/extensions/FileAttachment.test.js +63 -0
- package/dist/extensions/FixedDetails.d.ts +2 -0
- package/dist/extensions/FixedDetails.d.ts.map +1 -0
- package/dist/extensions/FixedDetails.js +100 -0
- package/dist/extensions/FixedDetails.test.d.ts +2 -0
- package/dist/extensions/FixedDetails.test.d.ts.map +1 -0
- package/dist/extensions/FixedDetails.test.js +40 -0
- package/dist/extensions/Indent.d.ts +11 -0
- package/dist/extensions/Indent.d.ts.map +1 -0
- package/dist/extensions/Indent.js +87 -0
- package/dist/extensions/Indent.test.d.ts +2 -0
- package/dist/extensions/Indent.test.d.ts.map +1 -0
- package/dist/extensions/Indent.test.js +78 -0
- package/dist/extensions/PdfBlock.d.ts +3 -0
- package/dist/extensions/PdfBlock.d.ts.map +1 -0
- package/dist/extensions/PdfBlock.js +301 -0
- package/dist/extensions/PdfBlock.test.d.ts +2 -0
- package/dist/extensions/PdfBlock.test.d.ts.map +1 -0
- package/dist/extensions/PdfBlock.test.js +51 -0
- package/dist/extensions/VideoBlock.d.ts +16 -0
- package/dist/extensions/VideoBlock.d.ts.map +1 -0
- package/dist/extensions/VideoBlock.js +87 -0
- package/dist/extensions/VideoBlock.test.d.ts +2 -0
- package/dist/extensions/VideoBlock.test.d.ts.map +1 -0
- package/dist/extensions/VideoBlock.test.js +54 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/styles/editor.css +698 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils/cn.d.ts +3 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/dist/utils/cn.js +5 -0
- package/dist/utils/cn.test.d.ts +2 -0
- package/dist/utils/cn.test.d.ts.map +1 -0
- package/dist/utils/cn.test.js +28 -0
- package/dist/utils/escape-html.d.ts +2 -0
- package/dist/utils/escape-html.d.ts.map +1 -0
- package/dist/utils/escape-html.js +7 -0
- package/dist/utils/escape-html.test.d.ts +2 -0
- package/dist/utils/escape-html.test.d.ts.map +1 -0
- package/dist/utils/escape-html.test.js +25 -0
- package/dist/utils/pdf.d.ts +9 -0
- package/dist/utils/pdf.d.ts.map +1 -0
- package/dist/utils/pdf.js +33 -0
- package/dist/utils/sanitize.d.ts +9 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +115 -0
- package/dist/utils/sanitize.test.d.ts +2 -0
- package/dist/utils/sanitize.test.d.ts.map +1 -0
- package/dist/utils/sanitize.test.js +206 -0
- package/package.json +76 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core";
|
|
2
|
+
const INDENT_STEP = 2; // em per level
|
|
3
|
+
const MAX_INDENT = 8;
|
|
4
|
+
export const Indent = Extension.create({
|
|
5
|
+
name: "indent",
|
|
6
|
+
addGlobalAttributes() {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
types: ["paragraph", "heading"],
|
|
10
|
+
attributes: {
|
|
11
|
+
indent: {
|
|
12
|
+
default: 0,
|
|
13
|
+
parseHTML: (element) => {
|
|
14
|
+
const ml = element.style.marginLeft;
|
|
15
|
+
if (!ml)
|
|
16
|
+
return 0;
|
|
17
|
+
const value = parseFloat(ml);
|
|
18
|
+
if (ml.endsWith("px"))
|
|
19
|
+
return Math.round(value / 40) || 0;
|
|
20
|
+
return Math.round(value / INDENT_STEP) || 0;
|
|
21
|
+
},
|
|
22
|
+
renderHTML: (attributes) => {
|
|
23
|
+
if (!attributes.indent || attributes.indent <= 0)
|
|
24
|
+
return {};
|
|
25
|
+
return { style: `margin-left: ${attributes.indent * INDENT_STEP}em` };
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
},
|
|
32
|
+
addCommands() {
|
|
33
|
+
return {
|
|
34
|
+
indent: () => ({ tr, state, dispatch }) => {
|
|
35
|
+
const { $from } = state.selection;
|
|
36
|
+
// listItem 내부에서는 기존 리스트 동작에 위임
|
|
37
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
38
|
+
if ($from.node(d).type.name === "listItem")
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const node = $from.parent;
|
|
42
|
+
if (node.type.name !== "paragraph" && node.type.name !== "heading")
|
|
43
|
+
return false;
|
|
44
|
+
const pos = $from.before($from.depth);
|
|
45
|
+
const currentIndent = node.attrs.indent || 0;
|
|
46
|
+
if (currentIndent >= MAX_INDENT)
|
|
47
|
+
return false;
|
|
48
|
+
if (dispatch) {
|
|
49
|
+
tr.setNodeMarkup(pos, undefined, {
|
|
50
|
+
...node.attrs,
|
|
51
|
+
indent: currentIndent + 1,
|
|
52
|
+
});
|
|
53
|
+
dispatch(tr);
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
outdent: () => ({ tr, state, dispatch }) => {
|
|
58
|
+
const { $from } = state.selection;
|
|
59
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
60
|
+
if ($from.node(d).type.name === "listItem")
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const node = $from.parent;
|
|
64
|
+
if (node.type.name !== "paragraph" && node.type.name !== "heading")
|
|
65
|
+
return false;
|
|
66
|
+
const pos = $from.before($from.depth);
|
|
67
|
+
const currentIndent = node.attrs.indent || 0;
|
|
68
|
+
if (currentIndent <= 0)
|
|
69
|
+
return false;
|
|
70
|
+
if (dispatch) {
|
|
71
|
+
tr.setNodeMarkup(pos, undefined, {
|
|
72
|
+
...node.attrs,
|
|
73
|
+
indent: currentIndent - 1,
|
|
74
|
+
});
|
|
75
|
+
dispatch(tr);
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
addKeyboardShortcuts() {
|
|
82
|
+
return {
|
|
83
|
+
Tab: () => this.editor.commands.indent(),
|
|
84
|
+
"Shift-Tab": () => this.editor.commands.outdent(),
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Indent.test.d.ts","sourceRoot":"","sources":["../../src/extensions/Indent.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
4
|
+
import { Indent } from './Indent';
|
|
5
|
+
describe('Indent extension', () => {
|
|
6
|
+
let editor;
|
|
7
|
+
function createEditor(content = '<p>Hello</p>') {
|
|
8
|
+
editor = new Editor({
|
|
9
|
+
element: document.createElement('div'),
|
|
10
|
+
extensions: [StarterKit, Indent],
|
|
11
|
+
content
|
|
12
|
+
});
|
|
13
|
+
return editor;
|
|
14
|
+
}
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
editor?.destroy();
|
|
17
|
+
});
|
|
18
|
+
it('registers with name "indent"', () => {
|
|
19
|
+
createEditor();
|
|
20
|
+
const ext = editor.extensionManager.extensions.find((e) => e.name === 'indent');
|
|
21
|
+
expect(ext).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
it('indent command increases indent level', () => {
|
|
24
|
+
createEditor('<p>Test</p>');
|
|
25
|
+
editor.commands.indent();
|
|
26
|
+
const doc = editor.getJSON();
|
|
27
|
+
const para = doc.content?.[0];
|
|
28
|
+
expect(para?.attrs?.indent).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
it('outdent command decreases indent level', () => {
|
|
31
|
+
createEditor('<p>Test</p>');
|
|
32
|
+
editor.commands.indent();
|
|
33
|
+
editor.commands.indent();
|
|
34
|
+
editor.commands.outdent();
|
|
35
|
+
const doc = editor.getJSON();
|
|
36
|
+
const para = doc.content?.[0];
|
|
37
|
+
expect(para?.attrs?.indent).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
it('does not outdent below 0', () => {
|
|
40
|
+
createEditor('<p>Test</p>');
|
|
41
|
+
const result = editor.commands.outdent();
|
|
42
|
+
expect(result).toBe(false);
|
|
43
|
+
const doc = editor.getJSON();
|
|
44
|
+
const para = doc.content?.[0];
|
|
45
|
+
expect(para?.attrs?.indent ?? 0).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
it('does not indent above MAX_INDENT (8)', () => {
|
|
48
|
+
createEditor('<p>Test</p>');
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
editor.commands.indent();
|
|
51
|
+
}
|
|
52
|
+
const doc = editor.getJSON();
|
|
53
|
+
const para = doc.content?.[0];
|
|
54
|
+
expect(para?.attrs?.indent).toBe(8);
|
|
55
|
+
});
|
|
56
|
+
it('renders margin-left style in HTML', () => {
|
|
57
|
+
createEditor('<p>Test</p>');
|
|
58
|
+
editor.commands.indent();
|
|
59
|
+
editor.commands.indent();
|
|
60
|
+
const html = editor.getHTML();
|
|
61
|
+
// 2 levels * 2em = 4em
|
|
62
|
+
expect(html).toContain('margin-left: 4em');
|
|
63
|
+
});
|
|
64
|
+
it('parses margin-left from existing HTML (px)', () => {
|
|
65
|
+
// 40px = 1 indent level (Math.round(40/40) = 1)
|
|
66
|
+
createEditor('<p style="margin-left: 40px">Indented</p>');
|
|
67
|
+
const doc = editor.getJSON();
|
|
68
|
+
const para = doc.content?.[0];
|
|
69
|
+
expect(para?.attrs?.indent).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
it('parses margin-left from existing HTML (em)', () => {
|
|
72
|
+
// 4em / 2em step = 2 indent levels
|
|
73
|
+
createEditor('<p style="margin-left: 4em">Indented</p>');
|
|
74
|
+
const doc = editor.getJSON();
|
|
75
|
+
const para = doc.content?.[0];
|
|
76
|
+
expect(para?.attrs?.indent).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PdfBlock.d.ts","sourceRoot":"","sources":["../../src/extensions/PdfBlock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,UAAU,EAAmB,MAAM,cAAc,CAAC;AAInE,eAAO,MAAM,QAAQ,sBAwTnB,CAAC"}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
import { getPdfJs } from "../utils/pdf";
|
|
3
|
+
export const PdfBlock = TiptapNode.create({
|
|
4
|
+
name: "pdfBlock",
|
|
5
|
+
group: "block",
|
|
6
|
+
atom: true,
|
|
7
|
+
addAttributes() {
|
|
8
|
+
return {
|
|
9
|
+
src: { default: null },
|
|
10
|
+
fileId: { default: null },
|
|
11
|
+
name: { default: "PDF" },
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
parseHTML() {
|
|
15
|
+
return [
|
|
16
|
+
// 하이브리드: data-pdf-id + data-pdf-src
|
|
17
|
+
{
|
|
18
|
+
tag: "div[data-pdf-id]",
|
|
19
|
+
getAttrs: (dom) => ({
|
|
20
|
+
fileId: dom.getAttribute("data-pdf-id"),
|
|
21
|
+
src: dom.getAttribute("data-pdf-src") || null,
|
|
22
|
+
name: dom.getAttribute("data-pdf-name") || "PDF",
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
// URL 직접 방식
|
|
26
|
+
{
|
|
27
|
+
tag: "div[data-pdf-src]",
|
|
28
|
+
getAttrs: (dom) => ({
|
|
29
|
+
src: dom.getAttribute("data-pdf-src"),
|
|
30
|
+
fileId: null,
|
|
31
|
+
name: dom.getAttribute("data-pdf-name") || "PDF",
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
// 레거시: <embed type="application/pdf">
|
|
35
|
+
{
|
|
36
|
+
tag: 'embed[type="application/pdf"]',
|
|
37
|
+
getAttrs: (dom) => {
|
|
38
|
+
const src = dom.getAttribute("src") || "";
|
|
39
|
+
const name = src.split("/").pop()?.replace(/\?.*$/, "") || "PDF";
|
|
40
|
+
return { src, fileId: null, name };
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
},
|
|
45
|
+
renderHTML({ HTMLAttributes }) {
|
|
46
|
+
const attrs = {};
|
|
47
|
+
if (HTMLAttributes.fileId)
|
|
48
|
+
attrs["data-pdf-id"] = HTMLAttributes.fileId;
|
|
49
|
+
if (HTMLAttributes.src)
|
|
50
|
+
attrs["data-pdf-src"] = HTMLAttributes.src;
|
|
51
|
+
attrs["data-pdf-name"] = HTMLAttributes.name || "PDF";
|
|
52
|
+
return [
|
|
53
|
+
"div",
|
|
54
|
+
mergeAttributes(attrs),
|
|
55
|
+
[
|
|
56
|
+
"p",
|
|
57
|
+
{},
|
|
58
|
+
[
|
|
59
|
+
"a",
|
|
60
|
+
{
|
|
61
|
+
href: HTMLAttributes.src || "#",
|
|
62
|
+
target: "_blank",
|
|
63
|
+
rel: "noopener noreferrer",
|
|
64
|
+
},
|
|
65
|
+
`\u{1F4C4} ${HTMLAttributes.name || "PDF"} (PDF)`,
|
|
66
|
+
],
|
|
67
|
+
],
|
|
68
|
+
];
|
|
69
|
+
},
|
|
70
|
+
addNodeView() {
|
|
71
|
+
return ({ node, getPos, editor }) => {
|
|
72
|
+
let destroyed = false;
|
|
73
|
+
let resizeObserver = null;
|
|
74
|
+
let resizeTimeout;
|
|
75
|
+
const dom = document.createElement("div");
|
|
76
|
+
dom.classList.add("my-4");
|
|
77
|
+
dom.contentEditable = "false";
|
|
78
|
+
dom.setAttribute("data-drag-handle", "");
|
|
79
|
+
const wrapper = document.createElement("div");
|
|
80
|
+
wrapper.className =
|
|
81
|
+
"border rounded-lg overflow-hidden bg-muted/30 transition-shadow";
|
|
82
|
+
wrapper.style.borderColor = "var(--border)";
|
|
83
|
+
dom.appendChild(wrapper);
|
|
84
|
+
// Header
|
|
85
|
+
const header = document.createElement("div");
|
|
86
|
+
header.className =
|
|
87
|
+
"flex items-center justify-between px-4 py-2 border-b border-border bg-background";
|
|
88
|
+
wrapper.appendChild(header);
|
|
89
|
+
const nameSpan = document.createElement("span");
|
|
90
|
+
nameSpan.className = "text-xs text-muted-foreground truncate";
|
|
91
|
+
nameSpan.style.maxWidth = "200px";
|
|
92
|
+
nameSpan.textContent = node.attrs.name || "PDF";
|
|
93
|
+
header.appendChild(nameSpan);
|
|
94
|
+
const btnGroup = document.createElement("div");
|
|
95
|
+
btnGroup.className = "flex items-center gap-1";
|
|
96
|
+
header.appendChild(btnGroup);
|
|
97
|
+
const openLink = document.createElement("a");
|
|
98
|
+
openLink.target = "_blank";
|
|
99
|
+
openLink.rel = "noopener noreferrer";
|
|
100
|
+
openLink.className =
|
|
101
|
+
"p-1 rounded hover:bg-muted transition-colors text-muted-foreground";
|
|
102
|
+
openLink.title = "\uC0C8 \uD0ED\uC5D0\uC11C \uC5F4\uAE30";
|
|
103
|
+
openLink.innerHTML =
|
|
104
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>';
|
|
105
|
+
btnGroup.appendChild(openLink);
|
|
106
|
+
const deleteBtn = document.createElement("button");
|
|
107
|
+
deleteBtn.type = "button";
|
|
108
|
+
deleteBtn.className =
|
|
109
|
+
"p-1 rounded hover:bg-destructive/10 transition-colors text-muted-foreground hover:text-destructive";
|
|
110
|
+
deleteBtn.title = "\uC0AD\uC81C";
|
|
111
|
+
deleteBtn.innerHTML =
|
|
112
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>';
|
|
113
|
+
deleteBtn.addEventListener("click", () => {
|
|
114
|
+
const pos = getPos();
|
|
115
|
+
if (pos != null) {
|
|
116
|
+
editor.commands.deleteRange({
|
|
117
|
+
from: pos,
|
|
118
|
+
to: pos + node.nodeSize,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
btnGroup.appendChild(deleteBtn);
|
|
123
|
+
// Content area
|
|
124
|
+
const contentArea = document.createElement("div");
|
|
125
|
+
contentArea.className =
|
|
126
|
+
"flex items-center justify-center p-4 bg-neutral-100 dark:bg-neutral-900";
|
|
127
|
+
wrapper.appendChild(contentArea);
|
|
128
|
+
const canvas = document.createElement("canvas");
|
|
129
|
+
canvas.className = "shadow-sm";
|
|
130
|
+
contentArea.appendChild(canvas);
|
|
131
|
+
const loadingDiv = document.createElement("div");
|
|
132
|
+
loadingDiv.className = "flex items-center justify-center p-8";
|
|
133
|
+
loadingDiv.innerHTML =
|
|
134
|
+
'<p class="text-sm text-muted-foreground animate-pulse">PDF \uB85C\uB529 \uC911...</p>';
|
|
135
|
+
contentArea.appendChild(loadingDiv);
|
|
136
|
+
canvas.style.display = "none";
|
|
137
|
+
let navDiv = null;
|
|
138
|
+
let currentPage = 1;
|
|
139
|
+
let totalPages = 0;
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
141
|
+
let pdfDoc = null;
|
|
142
|
+
let rendering = false;
|
|
143
|
+
async function renderPage() {
|
|
144
|
+
if (!pdfDoc || rendering || destroyed)
|
|
145
|
+
return;
|
|
146
|
+
rendering = true;
|
|
147
|
+
try {
|
|
148
|
+
const pageObj = await pdfDoc.getPage(currentPage);
|
|
149
|
+
if (destroyed)
|
|
150
|
+
return;
|
|
151
|
+
const unscaledViewport = pageObj.getViewport({ scale: 1 });
|
|
152
|
+
const availableWidth = contentArea.clientWidth - 32;
|
|
153
|
+
if (availableWidth <= 0) {
|
|
154
|
+
rendering = false;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const scale = availableWidth / unscaledViewport.width;
|
|
158
|
+
const viewport = pageObj.getViewport({ scale });
|
|
159
|
+
const dpr = window.devicePixelRatio || 1;
|
|
160
|
+
canvas.width = viewport.width * dpr;
|
|
161
|
+
canvas.height = viewport.height * dpr;
|
|
162
|
+
canvas.style.width = `${viewport.width}px`;
|
|
163
|
+
canvas.style.height = `${viewport.height}px`;
|
|
164
|
+
const ctx = canvas.getContext("2d");
|
|
165
|
+
if (!ctx) {
|
|
166
|
+
rendering = false;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
ctx.resetTransform();
|
|
170
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
171
|
+
ctx.scale(dpr, dpr);
|
|
172
|
+
await pageObj.render({ canvasContext: ctx, viewport }).promise;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
rendering = false;
|
|
176
|
+
}
|
|
177
|
+
if (navDiv) {
|
|
178
|
+
const pageSpan = navDiv.querySelector(".page-counter");
|
|
179
|
+
if (pageSpan)
|
|
180
|
+
pageSpan.textContent = `${currentPage} / ${totalPages}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function loadPdf(src) {
|
|
184
|
+
try {
|
|
185
|
+
const pdfjsLib = await getPdfJs();
|
|
186
|
+
if (destroyed)
|
|
187
|
+
return;
|
|
188
|
+
const loadingTask = pdfjsLib.getDocument(src);
|
|
189
|
+
pdfDoc = await loadingTask.promise;
|
|
190
|
+
if (destroyed)
|
|
191
|
+
return;
|
|
192
|
+
totalPages = pdfDoc.numPages;
|
|
193
|
+
loadingDiv.style.display = "none";
|
|
194
|
+
canvas.style.display = "";
|
|
195
|
+
if (totalPages > 1) {
|
|
196
|
+
navDiv = document.createElement("div");
|
|
197
|
+
navDiv.className =
|
|
198
|
+
"flex items-center justify-center gap-4 px-4 py-2 border-t border-border bg-background";
|
|
199
|
+
const prevBtn = document.createElement("button");
|
|
200
|
+
prevBtn.type = "button";
|
|
201
|
+
prevBtn.className =
|
|
202
|
+
"p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-30";
|
|
203
|
+
prevBtn.innerHTML =
|
|
204
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>';
|
|
205
|
+
prevBtn.addEventListener("click", () => {
|
|
206
|
+
if (currentPage > 1) {
|
|
207
|
+
currentPage--;
|
|
208
|
+
prevBtn.disabled = currentPage <= 1;
|
|
209
|
+
nextBtn.disabled = currentPage >= totalPages;
|
|
210
|
+
renderPage();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
navDiv.appendChild(prevBtn);
|
|
214
|
+
const pageSpan = document.createElement("span");
|
|
215
|
+
pageSpan.className = "text-sm tabular-nums page-counter";
|
|
216
|
+
pageSpan.textContent = `1 / ${totalPages}`;
|
|
217
|
+
navDiv.appendChild(pageSpan);
|
|
218
|
+
const nextBtn = document.createElement("button");
|
|
219
|
+
nextBtn.type = "button";
|
|
220
|
+
nextBtn.className =
|
|
221
|
+
"p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-30";
|
|
222
|
+
nextBtn.innerHTML =
|
|
223
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>';
|
|
224
|
+
nextBtn.addEventListener("click", () => {
|
|
225
|
+
if (currentPage < totalPages) {
|
|
226
|
+
currentPage++;
|
|
227
|
+
prevBtn.disabled = currentPage <= 1;
|
|
228
|
+
nextBtn.disabled = currentPage >= totalPages;
|
|
229
|
+
renderPage();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
navDiv.appendChild(nextBtn);
|
|
233
|
+
wrapper.appendChild(navDiv);
|
|
234
|
+
}
|
|
235
|
+
renderPage();
|
|
236
|
+
resizeObserver = new ResizeObserver(() => {
|
|
237
|
+
clearTimeout(resizeTimeout);
|
|
238
|
+
resizeTimeout = setTimeout(renderPage, 100);
|
|
239
|
+
});
|
|
240
|
+
resizeObserver.observe(contentArea);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
loadingDiv.innerHTML =
|
|
244
|
+
'<p class="text-sm text-muted-foreground">PDF\uB97C \uBD88\uB7EC\uC62C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.</p>';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// URL 해결 후 PDF 로드
|
|
248
|
+
if (node.attrs.src) {
|
|
249
|
+
openLink.href = node.attrs.src;
|
|
250
|
+
loadPdf(node.attrs.src);
|
|
251
|
+
}
|
|
252
|
+
else if (node.attrs.fileId) {
|
|
253
|
+
// resolver로 URL 획득
|
|
254
|
+
openLink.href = "#";
|
|
255
|
+
const resolver = editor.storage.fileAttachment?.resolver;
|
|
256
|
+
if (resolver) {
|
|
257
|
+
resolver(node.attrs.fileId)
|
|
258
|
+
.then((result) => {
|
|
259
|
+
openLink.href = result.src;
|
|
260
|
+
if (result.name)
|
|
261
|
+
nameSpan.textContent = result.name;
|
|
262
|
+
loadPdf(result.src);
|
|
263
|
+
})
|
|
264
|
+
.catch(() => {
|
|
265
|
+
loadingDiv.innerHTML =
|
|
266
|
+
'<p class="text-sm text-muted-foreground">PDF URL\uC744 \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.</p>';
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
loadingDiv.innerHTML =
|
|
271
|
+
`<p class="text-sm text-muted-foreground">PDF ID: ${node.attrs.fileId}</p>`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
dom,
|
|
276
|
+
update: (updatedNode) => {
|
|
277
|
+
if (updatedNode.type.name !== "pdfBlock")
|
|
278
|
+
return false;
|
|
279
|
+
return true;
|
|
280
|
+
},
|
|
281
|
+
selectNode: () => {
|
|
282
|
+
wrapper.style.borderColor = "var(--primary)";
|
|
283
|
+
wrapper.style.boxShadow =
|
|
284
|
+
"0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent)";
|
|
285
|
+
},
|
|
286
|
+
deselectNode: () => {
|
|
287
|
+
wrapper.style.borderColor = "var(--border)";
|
|
288
|
+
wrapper.style.boxShadow = "none";
|
|
289
|
+
},
|
|
290
|
+
destroy: () => {
|
|
291
|
+
destroyed = true;
|
|
292
|
+
clearTimeout(resizeTimeout);
|
|
293
|
+
resizeObserver?.disconnect();
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
addStorage() {
|
|
299
|
+
return {};
|
|
300
|
+
},
|
|
301
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PdfBlock.test.d.ts","sourceRoot":"","sources":["../../src/extensions/PdfBlock.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
4
|
+
import { PdfBlock } from './PdfBlock';
|
|
5
|
+
describe('PdfBlock extension', () => {
|
|
6
|
+
let editor;
|
|
7
|
+
function createEditor(content = '<p></p>') {
|
|
8
|
+
editor = new Editor({
|
|
9
|
+
element: document.createElement('div'),
|
|
10
|
+
extensions: [StarterKit, PdfBlock],
|
|
11
|
+
content
|
|
12
|
+
});
|
|
13
|
+
return editor;
|
|
14
|
+
}
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
editor?.destroy();
|
|
17
|
+
});
|
|
18
|
+
it('registers with name "pdfBlock"', () => {
|
|
19
|
+
createEditor();
|
|
20
|
+
const ext = editor.extensionManager.extensions.find((e) => e.name === 'pdfBlock');
|
|
21
|
+
expect(ext).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
it('parses <div data-pdf-src="url">', () => {
|
|
24
|
+
createEditor('<div data-pdf-src="https://example.com/doc.pdf" data-pdf-name="doc.pdf"></div>');
|
|
25
|
+
const doc = editor.getJSON();
|
|
26
|
+
const pdfNode = doc.content?.find((n) => n.type === 'pdfBlock');
|
|
27
|
+
expect(pdfNode).toBeDefined();
|
|
28
|
+
expect(pdfNode?.attrs?.src).toBe('https://example.com/doc.pdf');
|
|
29
|
+
expect(pdfNode?.attrs?.name).toBe('doc.pdf');
|
|
30
|
+
});
|
|
31
|
+
it('parses <div data-pdf-id="123">', () => {
|
|
32
|
+
createEditor('<div data-pdf-id="123" data-pdf-name="report.pdf"></div>');
|
|
33
|
+
const doc = editor.getJSON();
|
|
34
|
+
const pdfNode = doc.content?.find((n) => n.type === 'pdfBlock');
|
|
35
|
+
expect(pdfNode).toBeDefined();
|
|
36
|
+
expect(pdfNode?.attrs?.fileId).toBe('123');
|
|
37
|
+
expect(pdfNode?.attrs?.name).toBe('report.pdf');
|
|
38
|
+
});
|
|
39
|
+
it('renders correct HTML with src', () => {
|
|
40
|
+
createEditor('<div data-pdf-src="https://example.com/doc.pdf" data-pdf-name="doc.pdf"></div>');
|
|
41
|
+
const html = editor.getHTML();
|
|
42
|
+
expect(html).toContain('data-pdf-src="https://example.com/doc.pdf"');
|
|
43
|
+
expect(html).toContain('data-pdf-name="doc.pdf"');
|
|
44
|
+
});
|
|
45
|
+
it('uses default name "PDF" when name is missing', () => {
|
|
46
|
+
createEditor('<div data-pdf-src="https://example.com/x.pdf"></div>');
|
|
47
|
+
const doc = editor.getJSON();
|
|
48
|
+
const pdfNode = doc.content?.find((n) => n.type === 'pdfBlock');
|
|
49
|
+
expect(pdfNode?.attrs?.name).toBe('PDF');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Node } from "@tiptap/core";
|
|
2
|
+
export interface VideoBlockOptions {
|
|
3
|
+
HTMLAttributes: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
declare module "@tiptap/core" {
|
|
6
|
+
interface Commands<ReturnType> {
|
|
7
|
+
videoBlock: {
|
|
8
|
+
setVideoBlock: (attrs: {
|
|
9
|
+
src: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
}) => ReturnType;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export declare const VideoBlock: Node<VideoBlockOptions, any>;
|
|
16
|
+
//# sourceMappingURL=VideoBlock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoBlock.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoBlock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAErD,MAAM,WAAW,iBAAiB;IAChC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC;AAED,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,UAAU,EAAE;YACV,aAAa,EAAE,CAAC,KAAK,EAAE;gBAAE,GAAG,EAAE,MAAM,CAAC;gBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;aAAE,KAAK,UAAU,CAAC;SACtE,CAAC;KACH;CACF;AAED,eAAO,MAAM,UAAU,8BAkGrB,CAAC"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
export const VideoBlock = Node.create({
|
|
3
|
+
name: "videoBlock",
|
|
4
|
+
group: "block",
|
|
5
|
+
atom: true,
|
|
6
|
+
draggable: true,
|
|
7
|
+
addOptions() {
|
|
8
|
+
return { HTMLAttributes: {} };
|
|
9
|
+
},
|
|
10
|
+
addAttributes() {
|
|
11
|
+
return {
|
|
12
|
+
src: { default: null },
|
|
13
|
+
name: { default: "" },
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
parseHTML() {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
tag: 'div[data-video-src]',
|
|
20
|
+
getAttrs: (dom) => {
|
|
21
|
+
const el = dom;
|
|
22
|
+
return {
|
|
23
|
+
src: el.getAttribute("data-video-src"),
|
|
24
|
+
name: el.getAttribute("data-video-name") || "",
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
},
|
|
30
|
+
renderHTML({ HTMLAttributes }) {
|
|
31
|
+
return [
|
|
32
|
+
"div",
|
|
33
|
+
mergeAttributes(this.options.HTMLAttributes, {
|
|
34
|
+
"data-video-src": HTMLAttributes.src,
|
|
35
|
+
"data-video-name": HTMLAttributes.name || "",
|
|
36
|
+
}),
|
|
37
|
+
];
|
|
38
|
+
},
|
|
39
|
+
addNodeView() {
|
|
40
|
+
return ({ node, editor }) => {
|
|
41
|
+
const dom = document.createElement("div");
|
|
42
|
+
dom.style.cssText = "margin:8px 0;position:relative;";
|
|
43
|
+
const video = document.createElement("video");
|
|
44
|
+
video.src = node.attrs.src;
|
|
45
|
+
video.controls = true;
|
|
46
|
+
video.preload = "metadata";
|
|
47
|
+
video.style.cssText =
|
|
48
|
+
"width:100%;max-height:480px;border-radius:8px;background:#0b1020;";
|
|
49
|
+
video.setAttribute("controlslist", "nodownload");
|
|
50
|
+
video.setAttribute("playsinline", "");
|
|
51
|
+
dom.appendChild(video);
|
|
52
|
+
if (node.attrs.name) {
|
|
53
|
+
const label = document.createElement("p");
|
|
54
|
+
label.textContent = node.attrs.name;
|
|
55
|
+
label.style.cssText =
|
|
56
|
+
"margin:4px 0 0;font-size:12px;color:var(--muted-foreground, #718096);";
|
|
57
|
+
dom.appendChild(label);
|
|
58
|
+
}
|
|
59
|
+
if (editor.isEditable) {
|
|
60
|
+
const del = document.createElement("button");
|
|
61
|
+
del.type = "button";
|
|
62
|
+
del.textContent = "\u00D7";
|
|
63
|
+
del.style.cssText =
|
|
64
|
+
"position:absolute;top:8px;right:8px;width:28px;height:28px;border:none;background:rgba(0,0,0,0.6);color:#fff;font-size:18px;cursor:pointer;border-radius:50%;display:flex;align-items:center;justify-content:center;z-index:2;";
|
|
65
|
+
del.addEventListener("mouseenter", () => {
|
|
66
|
+
del.style.background = "var(--destructive, #FF6B6B)";
|
|
67
|
+
});
|
|
68
|
+
del.addEventListener("mouseleave", () => {
|
|
69
|
+
del.style.background = "rgba(0,0,0,0.6)";
|
|
70
|
+
});
|
|
71
|
+
del.addEventListener("click", () => {
|
|
72
|
+
const pos = editor.view.posAtDOM(dom, 0);
|
|
73
|
+
editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
|
|
74
|
+
});
|
|
75
|
+
dom.appendChild(del);
|
|
76
|
+
}
|
|
77
|
+
return { dom };
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
addCommands() {
|
|
81
|
+
return {
|
|
82
|
+
setVideoBlock: (attrs) => ({ chain }) => {
|
|
83
|
+
return chain().insertContent({ type: this.name, attrs }).run();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoBlock.test.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoBlock.test.ts"],"names":[],"mappings":""}
|