@teriusu/rich-editor 0.10.4 → 0.10.6
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/dist/components/TipTapEditor.svelte +11 -0
- package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
- package/dist/extensions/LegacyBlock.d.ts +34 -0
- package/dist/extensions/LegacyBlock.d.ts.map +1 -0
- package/dist/extensions/LegacyBlock.js +192 -0
- package/dist/extensions/LegacyBlock.test.d.ts +2 -0
- package/dist/extensions/LegacyBlock.test.d.ts.map +1 -0
- package/dist/extensions/LegacyBlock.test.js +95 -0
- package/dist/extensions/TiptapMidibus.d.ts +47 -0
- package/dist/extensions/TiptapMidibus.d.ts.map +1 -0
- package/dist/extensions/TiptapMidibus.js +139 -0
- package/dist/extensions/TiptapMidibus.test.d.ts +2 -0
- package/dist/extensions/TiptapMidibus.test.d.ts.map +1 -0
- package/dist/extensions/TiptapMidibus.test.js +89 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +91 -6
- package/dist/utils/sanitize.test.js +8 -5
- package/package.json +1 -1
|
@@ -192,6 +192,8 @@
|
|
|
192
192
|
import { MbusVideo } from "../extensions/MbusVideo";
|
|
193
193
|
import { ResizableImage } from "../extensions/ResizableImage";
|
|
194
194
|
import { VideoEmbed } from "../extensions/VideoEmbed";
|
|
195
|
+
import { TiptapMidibus } from "../extensions/TiptapMidibus";
|
|
196
|
+
import { LegacyBlock } from "../extensions/LegacyBlock";
|
|
195
197
|
import { CardBlock } from "../extensions/CardBlock";
|
|
196
198
|
import { MathInline, MathDisplay, type MathPrompt } from "../extensions/Math";
|
|
197
199
|
import type { UploadHandler, PromptHandler, ToolbarMode, ToolbarFeature } from "../types";
|
|
@@ -673,6 +675,15 @@
|
|
|
673
675
|
}),
|
|
674
676
|
MbusVideo,
|
|
675
677
|
VideoEmbed,
|
|
678
|
+
/*
|
|
679
|
+
* ⚠️ **정올 prod 저장 형식을 잃지 않기 위한 둘.** prod(`@seorii/tiptap`)와 lms 는
|
|
680
|
+
* 같은 Datastore 를 쓰는데, 여기 없으면 prod 글을 lms 에서 여는 것만으로 해당
|
|
681
|
+
* 블록이 통째로 증발한다(강의영상 글이 `<p>` 하나만 남은 적이 있다).
|
|
682
|
+
* `TiptapMidibus` 는 `<tiptap-midibus>` 를, `LegacyBlock` 은 `lite-youtube`·
|
|
683
|
+
* `div.iframe-wrapper`·`div.tiptap-columns` 를 **원본 그대로** 읽고 되쓴다.
|
|
684
|
+
*/
|
|
685
|
+
TiptapMidibus,
|
|
686
|
+
LegacyBlock,
|
|
676
687
|
CardBlock.configure({ promptBackground: onPromptCardBackground ?? null }),
|
|
677
688
|
/*
|
|
678
689
|
* ⚠️ **수학은 여기서 등록한다.** 예전엔 `MathInline`/`MathDisplay` 를 내보내기만 하고
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TipTapEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/components/TipTapEditor.svelte.ts"],"names":[],"mappings":"AA6JA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AA2BtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"TipTapEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/components/TipTapEditor.svelte.ts"],"names":[],"mappings":"AA6JA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AA2BtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAcjD,OAAO,EAA2B,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAC9E,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE1F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAEhE,KAAK,gBAAgB,GAAI;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,uDAAuD;IACvD,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,wCAAwC;IACxC,sBAAsB,CAAC,EAAE,aAAa,CAAC;IACvC,mDAAmD;IACnD,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,4BAA4B;IAC5B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;CAC7B,CAAC;AAqxBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 정올 prod 에만 있는 옛 블록 셋을 **원본 마크업 그대로 품는** 보존용 노드.
|
|
3
|
+
*
|
|
4
|
+
* - `<lite-youtube videoid=…>` — 유튜브 파사드 커스텀 엘리먼트 (prod 1 건)
|
|
5
|
+
* - `<div class="iframe-wrapper"><iframe …></div>` — 옛 임베드 (prod 69 건, 사멸 추세)
|
|
6
|
+
* - `<div class="tiptap-columns"><div class="tiptap-column">…</div></div>` — 옛 단 (prod 1 건)
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **왜 편집 가능한 노드로 바꾸지 않는가.** 정올 prod 와 lms 는 같은 Datastore 를
|
|
9
|
+
* 공유한다. 옛 단을 `Columns`(`div[data-type="columns"]`) 로 바꿔 저장하면 lms 에서는
|
|
10
|
+
* 잘 보이지만 **이번엔 prod 에디터가 그 형식을 모른다** — 손해를 반대로 옮길 뿐이다.
|
|
11
|
+
* 그래서 이 노드는 읽은 그대로 되쓴다: `parseHTML` 이 DOM 을 그대로 옮긴 명세(spec)로
|
|
12
|
+
* 뜨고, `renderHTML` 이 그 명세를 되돌려준다. 편집은 못 하지만 **아무것도 잃지 않는다.**
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **셀렉터 우선순위.** `Columns`/`Column` 에 이미 `div.tiptap-columns`·`div.tiptap-column`
|
|
15
|
+
* 규칙이 있다(기본 우선순위 50). 여기 규칙에 100 을 줘서 먼저 잡히게 한다. 그리고
|
|
16
|
+
* `transformLegacyHtml` 이 `class="tiptap-columns"` 를 `data-type="columns"` 로 미리
|
|
17
|
+
* 바꿔 버리면 여기까지 오지도 못하므로, 그 치환은 함께 제거했다(`utils/sanitize.ts`).
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ **바이트 보존의 한계 하나.** `editor.getHTML()` 은 결국 DOM 을 거쳐 직렬화되므로
|
|
20
|
+
* 값 없는 속성(`allowfullscreen`)은 `allowfullscreen=""` 로 정규화된다. prod 저장본도
|
|
21
|
+
* 같은 경로(`getHTML()`)로 만들어져 이미 `=""` 꼴이라 실사용에서는 어긋나지 않는다.
|
|
22
|
+
*/
|
|
23
|
+
import { Node } from "@tiptap/core";
|
|
24
|
+
/** ProseMirror `DOMOutputSpec` 과 같은 모양이되 **JSON 으로 저장 가능한** 형태. */
|
|
25
|
+
export type LegacySpec = string | LegacySpecElement;
|
|
26
|
+
export type LegacySpecElement = [string, Record<string, string>, ...LegacySpec[]];
|
|
27
|
+
export type LegacyBlockKind = "youtube" | "iframe" | "columns";
|
|
28
|
+
/**
|
|
29
|
+
* DOM 요소를 저장 가능한 명세로 옮긴다. 속성은 **적힌 순서 그대로**, 자식은 텍스트까지
|
|
30
|
+
* 그대로 옮긴다. `on*` 만 떨어뜨린다 — 그것까지 되쓰면 보존이 아니라 XSS 통로가 된다.
|
|
31
|
+
*/
|
|
32
|
+
export declare function elementToSpec(el: Element): LegacySpecElement;
|
|
33
|
+
export declare const LegacyBlock: Node<any, any>;
|
|
34
|
+
//# sourceMappingURL=LegacyBlock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LegacyBlock.d.ts","sourceRoot":"","sources":["../../src/extensions/LegacyBlock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAGpC,mEAAmE;AACnE,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,iBAAiB,CAAC;AACpD,MAAM,MAAM,iBAAiB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,UAAU,EAAE,CAAC,CAAC;AAElF,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAW/D;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,GAAG,iBAAiB,CAqB5D;AA6DD,eAAO,MAAM,WAAW,gBAiFtB,CAAC"}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 정올 prod 에만 있는 옛 블록 셋을 **원본 마크업 그대로 품는** 보존용 노드.
|
|
3
|
+
*
|
|
4
|
+
* - `<lite-youtube videoid=…>` — 유튜브 파사드 커스텀 엘리먼트 (prod 1 건)
|
|
5
|
+
* - `<div class="iframe-wrapper"><iframe …></div>` — 옛 임베드 (prod 69 건, 사멸 추세)
|
|
6
|
+
* - `<div class="tiptap-columns"><div class="tiptap-column">…</div></div>` — 옛 단 (prod 1 건)
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **왜 편집 가능한 노드로 바꾸지 않는가.** 정올 prod 와 lms 는 같은 Datastore 를
|
|
9
|
+
* 공유한다. 옛 단을 `Columns`(`div[data-type="columns"]`) 로 바꿔 저장하면 lms 에서는
|
|
10
|
+
* 잘 보이지만 **이번엔 prod 에디터가 그 형식을 모른다** — 손해를 반대로 옮길 뿐이다.
|
|
11
|
+
* 그래서 이 노드는 읽은 그대로 되쓴다: `parseHTML` 이 DOM 을 그대로 옮긴 명세(spec)로
|
|
12
|
+
* 뜨고, `renderHTML` 이 그 명세를 되돌려준다. 편집은 못 하지만 **아무것도 잃지 않는다.**
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **셀렉터 우선순위.** `Columns`/`Column` 에 이미 `div.tiptap-columns`·`div.tiptap-column`
|
|
15
|
+
* 규칙이 있다(기본 우선순위 50). 여기 규칙에 100 을 줘서 먼저 잡히게 한다. 그리고
|
|
16
|
+
* `transformLegacyHtml` 이 `class="tiptap-columns"` 를 `data-type="columns"` 로 미리
|
|
17
|
+
* 바꿔 버리면 여기까지 오지도 못하므로, 그 치환은 함께 제거했다(`utils/sanitize.ts`).
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ **바이트 보존의 한계 하나.** `editor.getHTML()` 은 결국 DOM 을 거쳐 직렬화되므로
|
|
20
|
+
* 값 없는 속성(`allowfullscreen`)은 `allowfullscreen=""` 로 정규화된다. prod 저장본도
|
|
21
|
+
* 같은 경로(`getHTML()`)로 만들어져 이미 `=""` 꼴이라 실사용에서는 어긋나지 않는다.
|
|
22
|
+
*/
|
|
23
|
+
import { Node } from "@tiptap/core";
|
|
24
|
+
/** 되쓸 이유가 없는(그리고 되쓰면 위험한) 것들. prod 저장본에는 원래 들어 있지 않다. */
|
|
25
|
+
const SKIP_TAGS = new Set(["script", "style", "noscript", "template"]);
|
|
26
|
+
/** `Columns`/`Column` 의 같은 셀렉터보다 먼저 잡혀야 한다(기본 50). */
|
|
27
|
+
const LEGACY_PARSE_PRIORITY = 100;
|
|
28
|
+
const ELEMENT_NODE = 1;
|
|
29
|
+
const TEXT_NODE = 3;
|
|
30
|
+
/**
|
|
31
|
+
* DOM 요소를 저장 가능한 명세로 옮긴다. 속성은 **적힌 순서 그대로**, 자식은 텍스트까지
|
|
32
|
+
* 그대로 옮긴다. `on*` 만 떨어뜨린다 — 그것까지 되쓰면 보존이 아니라 XSS 통로가 된다.
|
|
33
|
+
*/
|
|
34
|
+
export function elementToSpec(el) {
|
|
35
|
+
const attrs = {};
|
|
36
|
+
for (const attr of Array.from(el.attributes)) {
|
|
37
|
+
const name = attr.name.toLowerCase();
|
|
38
|
+
if (name.startsWith("on"))
|
|
39
|
+
continue;
|
|
40
|
+
attrs[name] = attr.value;
|
|
41
|
+
}
|
|
42
|
+
const children = [];
|
|
43
|
+
for (const child of Array.from(el.childNodes)) {
|
|
44
|
+
if (child.nodeType === TEXT_NODE) {
|
|
45
|
+
const text = child.nodeValue ?? "";
|
|
46
|
+
if (text)
|
|
47
|
+
children.push(text);
|
|
48
|
+
}
|
|
49
|
+
else if (child.nodeType === ELEMENT_NODE) {
|
|
50
|
+
const childEl = child;
|
|
51
|
+
if (SKIP_TAGS.has(childEl.tagName.toLowerCase()))
|
|
52
|
+
continue;
|
|
53
|
+
children.push(elementToSpec(childEl));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [el.tagName.toLowerCase(), attrs, ...children];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ **깊은 복사가 필수다.** ProseMirror 1.25 는 `renderSpec` 에서 "노드 attrs 안에 있는
|
|
60
|
+
* 배열이 그대로 DOM 명세로 넘어왔는지" 를 검사해 XSS 로 보고 던진다
|
|
61
|
+
* (`Using an array from an attribute object as a DOM spec`). 검사는 attrs 를 훑어 **닿는
|
|
62
|
+
* 모든 배열**을 모으므로 얕은 복사로는 자식에서 그대로 걸린다.
|
|
63
|
+
*/
|
|
64
|
+
function cloneSpec(spec) {
|
|
65
|
+
if (typeof spec === "string")
|
|
66
|
+
return spec;
|
|
67
|
+
const [tag, attrs, ...children] = spec;
|
|
68
|
+
return [tag, { ...attrs }, ...children.map(cloneSpec)];
|
|
69
|
+
}
|
|
70
|
+
/** 자리표시자에 보여 줄 미리보기 글자. */
|
|
71
|
+
function specText(spec) {
|
|
72
|
+
if (typeof spec === "string")
|
|
73
|
+
return spec;
|
|
74
|
+
const [, , ...children] = spec;
|
|
75
|
+
return children.map(specText).join("");
|
|
76
|
+
}
|
|
77
|
+
/** 명세 트리에서 첫 번째 `tag` 요소를 찾는다(임베드 주소를 꺼내려고). */
|
|
78
|
+
function findSpec(spec, tag) {
|
|
79
|
+
if (typeof spec === "string")
|
|
80
|
+
return null;
|
|
81
|
+
if (spec[0] === tag)
|
|
82
|
+
return spec;
|
|
83
|
+
const [, , ...children] = spec;
|
|
84
|
+
for (const child of children) {
|
|
85
|
+
const found = findSpec(child, tag);
|
|
86
|
+
if (found)
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function detectKind(el) {
|
|
92
|
+
const tag = el.tagName.toLowerCase();
|
|
93
|
+
if (tag === "lite-youtube")
|
|
94
|
+
return "youtube";
|
|
95
|
+
if (el.classList?.contains("tiptap-columns"))
|
|
96
|
+
return "columns";
|
|
97
|
+
return "iframe";
|
|
98
|
+
}
|
|
99
|
+
const KIND_LABEL = {
|
|
100
|
+
youtube: "유튜브 영상",
|
|
101
|
+
iframe: "임베드",
|
|
102
|
+
columns: "단 나누기"
|
|
103
|
+
};
|
|
104
|
+
/** 자리표시자의 "새 탭에서 보기" 주소. 없으면 링크를 안 만든다. */
|
|
105
|
+
function outboundUrl(kind, spec) {
|
|
106
|
+
if (!spec)
|
|
107
|
+
return null;
|
|
108
|
+
if (kind === "youtube") {
|
|
109
|
+
const videoId = spec[1]?.videoid;
|
|
110
|
+
return videoId ? `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}` : null;
|
|
111
|
+
}
|
|
112
|
+
if (kind === "iframe") {
|
|
113
|
+
const frame = findSpec(spec, "iframe");
|
|
114
|
+
const src = frame?.[1]?.src ?? "";
|
|
115
|
+
return /^https?:\/\//i.test(src) ? src : null;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
export const LegacyBlock = Node.create({
|
|
120
|
+
name: "legacyBlock",
|
|
121
|
+
group: "block",
|
|
122
|
+
atom: true,
|
|
123
|
+
selectable: true,
|
|
124
|
+
draggable: false,
|
|
125
|
+
addAttributes() {
|
|
126
|
+
return {
|
|
127
|
+
/* 원본 마크업. `rendered: false` — `renderHTML` 이 직접 되뱉는다. */
|
|
128
|
+
spec: {
|
|
129
|
+
default: null,
|
|
130
|
+
rendered: false,
|
|
131
|
+
parseHTML: (element) => elementToSpec(element)
|
|
132
|
+
},
|
|
133
|
+
kind: {
|
|
134
|
+
default: "iframe",
|
|
135
|
+
rendered: false,
|
|
136
|
+
parseHTML: (element) => detectKind(element)
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
parseHTML() {
|
|
141
|
+
return [
|
|
142
|
+
{ tag: "lite-youtube", priority: LEGACY_PARSE_PRIORITY },
|
|
143
|
+
{ tag: "div.iframe-wrapper", priority: LEGACY_PARSE_PRIORITY },
|
|
144
|
+
{ tag: "div.tiptap-columns", priority: LEGACY_PARSE_PRIORITY }
|
|
145
|
+
];
|
|
146
|
+
},
|
|
147
|
+
renderHTML({ node }) {
|
|
148
|
+
const spec = node.attrs.spec;
|
|
149
|
+
// 명세가 없는 노드는 만들 길이 없지만(삽입 명령이 없다), 스키마상 방어해 둔다.
|
|
150
|
+
if (!spec)
|
|
151
|
+
return ["div", {}];
|
|
152
|
+
return cloneSpec(spec);
|
|
153
|
+
},
|
|
154
|
+
addNodeView() {
|
|
155
|
+
return ({ node }) => {
|
|
156
|
+
const kind = (node.attrs.kind ?? "iframe");
|
|
157
|
+
const spec = node.attrs.spec;
|
|
158
|
+
const dom = document.createElement("div");
|
|
159
|
+
dom.setAttribute("data-type", "legacyBlock");
|
|
160
|
+
dom.setAttribute("data-legacy-kind", kind);
|
|
161
|
+
dom.setAttribute("data-node-view-wrapper", "");
|
|
162
|
+
dom.style.cssText =
|
|
163
|
+
"margin:8px 0;padding:16px;border:1px dashed rgba(120,130,150,0.5);border-radius:8px;background:rgba(120,130,150,0.06);box-sizing:border-box;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;";
|
|
164
|
+
const label = document.createElement("span");
|
|
165
|
+
label.textContent = `${KIND_LABEL[kind]} (옛 형식 · 편집 불가)`;
|
|
166
|
+
label.style.cssText = "font-size:13px;font-weight:600;opacity:0.8;";
|
|
167
|
+
dom.appendChild(label);
|
|
168
|
+
/*
|
|
169
|
+
* 저장된 내용을 **글자로만** 보여 준다. 옛 단은 본문이 통째로 들어 있어서,
|
|
170
|
+
* 이걸 안 보여 주면 작성자는 무엇이 들었는지 모른 채 지우게 된다.
|
|
171
|
+
*/
|
|
172
|
+
const preview = spec ? specText(spec).replace(/\s+/g, " ").trim() : "";
|
|
173
|
+
if (preview) {
|
|
174
|
+
const body = document.createElement("span");
|
|
175
|
+
body.textContent = preview.length > 200 ? `${preview.slice(0, 200)}…` : preview;
|
|
176
|
+
body.style.cssText = "font-size:13px;line-height:1.6;opacity:0.75;";
|
|
177
|
+
dom.appendChild(body);
|
|
178
|
+
}
|
|
179
|
+
const url = outboundUrl(kind, spec);
|
|
180
|
+
if (url) {
|
|
181
|
+
const open = document.createElement("a");
|
|
182
|
+
open.href = url;
|
|
183
|
+
open.target = "_blank";
|
|
184
|
+
open.rel = "noopener noreferrer";
|
|
185
|
+
open.textContent = "새 탭에서 보기";
|
|
186
|
+
open.style.cssText = "font-size:13px;font-weight:600;text-decoration:underline;";
|
|
187
|
+
dom.appendChild(open);
|
|
188
|
+
}
|
|
189
|
+
return { dom };
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LegacyBlock.test.d.ts","sourceRoot":"","sources":["../../src/extensions/LegacyBlock.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
4
|
+
import { LegacyBlock } from './LegacyBlock';
|
|
5
|
+
import { Columns } from './Columns';
|
|
6
|
+
import { Column } from './Column';
|
|
7
|
+
import { sanitizeHtml, transformLegacyHtml } from '../utils/sanitize';
|
|
8
|
+
/**
|
|
9
|
+
* 옛 블록 셋은 **편집하지 않고 보존한다.** 정올 prod 와 lms 가 같은 Datastore 를 쓰므로,
|
|
10
|
+
* 형식을 갈아 끼우면 lms 에서는 보이고 prod 에서는 사라진다 — 손해가 반대로 옮겨갈 뿐이다.
|
|
11
|
+
*/
|
|
12
|
+
describe('LegacyBlock — 옛 정올 블록 보존', () => {
|
|
13
|
+
let editor;
|
|
14
|
+
function createEditor(content) {
|
|
15
|
+
editor = new Editor({
|
|
16
|
+
element: document.createElement('div'),
|
|
17
|
+
// `Columns`/`Column` 도 함께 넣는다 — 셀렉터가 겹치므로 우선순위를 실제로 확인해야 한다.
|
|
18
|
+
extensions: [StarterKit, LegacyBlock, Columns, Column],
|
|
19
|
+
content
|
|
20
|
+
});
|
|
21
|
+
return editor;
|
|
22
|
+
}
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
editor?.destroy();
|
|
25
|
+
});
|
|
26
|
+
const YOUTUBE = '<lite-youtube videoid="dQw4w9WgXcQ" params="rel=0"></lite-youtube>';
|
|
27
|
+
/*
|
|
28
|
+
* ⚠️ 값 없는 속성(`allowfullscreen`)은 `getHTML()` 이 DOM 을 거치며 `=""` 로 정규화된다.
|
|
29
|
+
* prod 저장본도 같은 경로(`getHTML()`)로 만들어져 이미 `=""` 꼴이라 실사용에서는 어긋나지
|
|
30
|
+
* 않는다 — 그래서 표본도 `=""` 로 적는다.
|
|
31
|
+
*/
|
|
32
|
+
const IFRAME = '<div class="iframe-wrapper"><iframe src="https://jungol.co.kr/embed/1" width="640" height="360" frameborder="0" allowfullscreen=""></iframe></div>';
|
|
33
|
+
const COLUMNS = '<div class="tiptap-columns"><div class="tiptap-column"><p>왼쪽</p></div><div class="tiptap-column"><p>오른쪽</p></div></div>';
|
|
34
|
+
it('lite-youtube 를 왕복에서 바이트 보존한다', () => {
|
|
35
|
+
createEditor(YOUTUBE);
|
|
36
|
+
expect(editor.getJSON().content?.[0]?.type).toBe('legacyBlock');
|
|
37
|
+
expect(editor.getHTML()).toBe(YOUTUBE);
|
|
38
|
+
});
|
|
39
|
+
it('div.iframe-wrapper 를 왕복에서 바이트 보존한다', () => {
|
|
40
|
+
createEditor(IFRAME);
|
|
41
|
+
expect(editor.getHTML()).toBe(IFRAME);
|
|
42
|
+
});
|
|
43
|
+
/* ⚠️ `Columns` 의 같은 셀렉터(우선순위 50)보다 먼저 잡혀야 한다. */
|
|
44
|
+
it('div.tiptap-columns 를 Columns 보다 먼저 잡아 원본 그대로 되쓴다', () => {
|
|
45
|
+
createEditor(COLUMNS);
|
|
46
|
+
const node = editor.getJSON().content?.[0];
|
|
47
|
+
expect(node?.type).toBe('legacyBlock');
|
|
48
|
+
expect(node?.attrs?.kind).toBe('columns');
|
|
49
|
+
expect(editor.getHTML()).toBe(COLUMNS);
|
|
50
|
+
});
|
|
51
|
+
it('rich-editor 자신의 단(div[data-type="columns"])은 그대로 Columns 가 맡는다', () => {
|
|
52
|
+
createEditor('<div data-type="columns"><div data-type="column"><p>A</p></div><div data-type="column"><p>B</p></div></div>');
|
|
53
|
+
expect(editor.getJSON().content?.[0]?.type).toBe('columns');
|
|
54
|
+
});
|
|
55
|
+
it('앞뒤 본문이 섞여도 셋 다 그대로다', () => {
|
|
56
|
+
const html = `<p>앞</p>${YOUTUBE}${IFRAME}${COLUMNS}<p>뒤</p>`;
|
|
57
|
+
createEditor(html);
|
|
58
|
+
expect(editor.getHTML()).toBe(html);
|
|
59
|
+
});
|
|
60
|
+
/* ⚠️ 회귀 표적. 노드가 없으면 파서가 통째로 버린다. */
|
|
61
|
+
it('노드가 없으면 세 블록이 전부 사라진다는 것을 함께 박아 둔다', () => {
|
|
62
|
+
const bare = new Editor({
|
|
63
|
+
element: document.createElement('div'),
|
|
64
|
+
extensions: [StarterKit],
|
|
65
|
+
content: YOUTUBE + IFRAME
|
|
66
|
+
});
|
|
67
|
+
const html = bare.getHTML();
|
|
68
|
+
expect(html).not.toContain('lite-youtube');
|
|
69
|
+
expect(html).not.toContain('iframe');
|
|
70
|
+
bare.destroy();
|
|
71
|
+
});
|
|
72
|
+
it('on* 은 되쓰지 않는다', () => {
|
|
73
|
+
createEditor('<lite-youtube videoid="v" onerror="alert(1)"></lite-youtube>');
|
|
74
|
+
expect(editor.getHTML()).toBe('<lite-youtube videoid="v"></lite-youtube>');
|
|
75
|
+
});
|
|
76
|
+
it('transformLegacyHtml 이 옛 단을 더는 갈아 끼우지 않는다', () => {
|
|
77
|
+
// 갈아 끼우면 prod 가 모르는 형식으로 저장돼 이번엔 prod 쪽에서 단이 사라진다.
|
|
78
|
+
expect(transformLegacyHtml(COLUMNS)).toBe(COLUMNS);
|
|
79
|
+
});
|
|
80
|
+
describe('sanitizeHtml', () => {
|
|
81
|
+
it('세 블록을 정적 렌더에서도 살려 둔다', () => {
|
|
82
|
+
const clean = sanitizeHtml(YOUTUBE + IFRAME + COLUMNS);
|
|
83
|
+
expect(clean).toContain('<lite-youtube');
|
|
84
|
+
expect(clean).toContain('videoid="dQw4w9WgXcQ"');
|
|
85
|
+
expect(clean).toContain('<iframe');
|
|
86
|
+
expect(clean).toContain('src="https://jungol.co.kr/embed/1"');
|
|
87
|
+
expect(clean).toContain('class="tiptap-columns"');
|
|
88
|
+
});
|
|
89
|
+
it('허용하지 않은 호스트의 iframe 은 src 를 뗀다', () => {
|
|
90
|
+
const clean = sanitizeHtml('<iframe src="https://evil.example/x"></iframe>');
|
|
91
|
+
expect(clean).toContain('<iframe');
|
|
92
|
+
expect(clean).not.toContain('evil.example');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 정올 prod 저장 형식인 `<tiptap-midibus …>` 를 **그대로 읽고 그대로 되쓰는** 노드.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **`MbusVideo` 와 다른 노드다.** 둘 다 mbus 영상이지만 저장 형식이 다르다 —
|
|
5
|
+
* `MbusVideo` 는 lms(rich-editor) 가 만든 `div[data-mbus-src]` 이고, 이쪽은 정올 prod
|
|
6
|
+
* 에디터(`@seorii/tiptap`)가 만든 커스텀 태그다. 정올 prod 와 lms 는 **같은 Datastore 를
|
|
7
|
+
* 공유**하므로, 한쪽이 모르는 형식을 만나면 그 블록이 통째로 사라진다. 실제로 lms 에서
|
|
8
|
+
* 강의영상 글을 열면 본문이 `<p>` 하나만 남고 비었다(prod 게시글·댓글 15,479 건 중
|
|
9
|
+
* 181 건이 이 형식이고, 2026 년에도 51 건이 새로 쌓였다 — 사멸한 형식이 아니다).
|
|
10
|
+
*
|
|
11
|
+
* 그래서 이 노드의 계약은 **재생이 아니라 보존**이다:
|
|
12
|
+
* 1. `parseHTML` 이 여는 태그의 속성을 **순서까지 그대로** `rawAttrs` 에 담는다.
|
|
13
|
+
* 2. `renderHTML` 은 `rawAttrs` 가 있으면 그것을 그대로 되뱉는다 → 왕복에서 바이트 보존.
|
|
14
|
+
* 3. 화면에는 **읽기 전용 자리표시자**만 세운다(아래 이유).
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **재생 UI 는 일부러 포팅하지 않았다.** prod 플레이어
|
|
17
|
+
* (`trinity/apps/jungol/src/components/ui/tiptap/midibus/MidibusInner.svelte`)는
|
|
18
|
+
* ① 로그인 계정 id 로 `uuid` 를 조립하고 ② `GET /midibus/start/{id}` 로 이어보기 지점을
|
|
19
|
+
* 받아오며 ③ PIP 헬퍼를 쓴다 — 셋 다 **호스트 앱의 API 클라이언트·세션**이 있어야 하는
|
|
20
|
+
* 것이라 패키지 안으로 들어올 수 없다. 게다가 정올은 전 문서에 `COEP: require-corp` 를
|
|
21
|
+
* 걸어 두어서, `credentialless` 를 모르는 파이어폭스·사파리에서는 prod 에서도 이 영상이
|
|
22
|
+
* 이미 **빈 회색 박스**다. 재생을 어설프게 옮기면 그 미해결 문제를 그대로 물려받는다.
|
|
23
|
+
* 대신 자리표시자에 플레이어 새 탭 링크를 둔다 — 비로그인 prod 가 쓰는 것과 같은 주소다.
|
|
24
|
+
*/
|
|
25
|
+
import { Node } from "@tiptap/core";
|
|
26
|
+
export interface TiptapMidibusOptions {
|
|
27
|
+
HTMLAttributes: Record<string, unknown>;
|
|
28
|
+
/** 자리표시자의 "새 탭에서 보기" 가 향할 플레이어 주소 앞부분. */
|
|
29
|
+
playerBaseUrl: string;
|
|
30
|
+
}
|
|
31
|
+
declare module "@tiptap/core" {
|
|
32
|
+
interface Commands<ReturnType> {
|
|
33
|
+
tiptapMidibus: {
|
|
34
|
+
setTiptapMidibus: (attrs: {
|
|
35
|
+
id: string;
|
|
36
|
+
start?: number | string;
|
|
37
|
+
uuid?: string;
|
|
38
|
+
width?: string;
|
|
39
|
+
height?: string;
|
|
40
|
+
}) => ReturnType;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** 여는 태그에 실제로 적혀 있던 속성을 **적힌 순서 그대로** 담는다. */
|
|
45
|
+
export type MidibusRawAttrs = Record<string, string>;
|
|
46
|
+
export declare const TiptapMidibus: Node<TiptapMidibusOptions, any>;
|
|
47
|
+
//# sourceMappingURL=TiptapMidibus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TiptapMidibus.d.ts","sourceRoot":"","sources":["../../src/extensions/TiptapMidibus.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAErD,MAAM,WAAW,oBAAoB;IACpC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,0CAA0C;IAC1C,aAAa,EAAE,MAAM,CAAC;CACtB;AAED,OAAO,QAAQ,cAAc,CAAC;IAC7B,UAAU,QAAQ,CAAC,UAAU;QAC5B,aAAa,EAAE;YACd,gBAAgB,EAAE,CAAC,KAAK,EAAE;gBACzB,EAAE,EAAE,MAAM,CAAC;gBACX,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;gBACxB,IAAI,CAAC,EAAE,MAAM,CAAC;gBACd,KAAK,CAAC,EAAE,MAAM,CAAC;gBACf,MAAM,CAAC,EAAE,MAAM,CAAC;aAChB,KAAK,UAAU,CAAC;SACjB,CAAC;KACF;CACD;AAED,8CAA8C;AAC9C,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAiBrD,eAAO,MAAM,aAAa,iCAgHxB,CAAC"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 정올 prod 저장 형식인 `<tiptap-midibus …>` 를 **그대로 읽고 그대로 되쓰는** 노드.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **`MbusVideo` 와 다른 노드다.** 둘 다 mbus 영상이지만 저장 형식이 다르다 —
|
|
5
|
+
* `MbusVideo` 는 lms(rich-editor) 가 만든 `div[data-mbus-src]` 이고, 이쪽은 정올 prod
|
|
6
|
+
* 에디터(`@seorii/tiptap`)가 만든 커스텀 태그다. 정올 prod 와 lms 는 **같은 Datastore 를
|
|
7
|
+
* 공유**하므로, 한쪽이 모르는 형식을 만나면 그 블록이 통째로 사라진다. 실제로 lms 에서
|
|
8
|
+
* 강의영상 글을 열면 본문이 `<p>` 하나만 남고 비었다(prod 게시글·댓글 15,479 건 중
|
|
9
|
+
* 181 건이 이 형식이고, 2026 년에도 51 건이 새로 쌓였다 — 사멸한 형식이 아니다).
|
|
10
|
+
*
|
|
11
|
+
* 그래서 이 노드의 계약은 **재생이 아니라 보존**이다:
|
|
12
|
+
* 1. `parseHTML` 이 여는 태그의 속성을 **순서까지 그대로** `rawAttrs` 에 담는다.
|
|
13
|
+
* 2. `renderHTML` 은 `rawAttrs` 가 있으면 그것을 그대로 되뱉는다 → 왕복에서 바이트 보존.
|
|
14
|
+
* 3. 화면에는 **읽기 전용 자리표시자**만 세운다(아래 이유).
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **재생 UI 는 일부러 포팅하지 않았다.** prod 플레이어
|
|
17
|
+
* (`trinity/apps/jungol/src/components/ui/tiptap/midibus/MidibusInner.svelte`)는
|
|
18
|
+
* ① 로그인 계정 id 로 `uuid` 를 조립하고 ② `GET /midibus/start/{id}` 로 이어보기 지점을
|
|
19
|
+
* 받아오며 ③ PIP 헬퍼를 쓴다 — 셋 다 **호스트 앱의 API 클라이언트·세션**이 있어야 하는
|
|
20
|
+
* 것이라 패키지 안으로 들어올 수 없다. 게다가 정올은 전 문서에 `COEP: require-corp` 를
|
|
21
|
+
* 걸어 두어서, `credentialless` 를 모르는 파이어폭스·사파리에서는 prod 에서도 이 영상이
|
|
22
|
+
* 이미 **빈 회색 박스**다. 재생을 어설프게 옮기면 그 미해결 문제를 그대로 물려받는다.
|
|
23
|
+
* 대신 자리표시자에 플레이어 새 탭 링크를 둔다 — 비로그인 prod 가 쓰는 것과 같은 주소다.
|
|
24
|
+
*/
|
|
25
|
+
import { Node, mergeAttributes } from "@tiptap/core";
|
|
26
|
+
/**
|
|
27
|
+
* ⚠️ `on*` 만 걸러내고 나머지는 손대지 않는다. prod 가 `data-resize-*`·`data-bubble-menu`
|
|
28
|
+
* 같은 것을 붙여 저장해도 여기서 살아남아야 왕복이 성립한다 — 아는 속성만 남기면
|
|
29
|
+
* 모르는 속성이 조용히 증발한다.
|
|
30
|
+
*/
|
|
31
|
+
function readRawAttrs(el) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const attr of Array.from(el.attributes)) {
|
|
34
|
+
const name = attr.name.toLowerCase();
|
|
35
|
+
if (name.startsWith("on"))
|
|
36
|
+
continue;
|
|
37
|
+
out[name] = attr.value;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
export const TiptapMidibus = Node.create({
|
|
42
|
+
name: "tiptapMidibus",
|
|
43
|
+
group: "block",
|
|
44
|
+
atom: true,
|
|
45
|
+
draggable: true,
|
|
46
|
+
inline: false,
|
|
47
|
+
addOptions() {
|
|
48
|
+
return {
|
|
49
|
+
HTMLAttributes: {},
|
|
50
|
+
playerBaseUrl: "https://play.mbus.tv/v1/hls"
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
/*
|
|
54
|
+
* 기본값은 **prod 와 한 글자도 다르면 안 된다**(prod `midibus/index.ts`).
|
|
55
|
+
* 여기서 새로 넣은 영상이 prod 에디터로 넘어가도 같은 꼴로 보여야 하기 때문이다.
|
|
56
|
+
*/
|
|
57
|
+
addAttributes() {
|
|
58
|
+
return {
|
|
59
|
+
id: { default: "" },
|
|
60
|
+
start: { default: 0 },
|
|
61
|
+
uuid: { default: "" },
|
|
62
|
+
width: { default: "100%" },
|
|
63
|
+
height: { default: "600" },
|
|
64
|
+
/*
|
|
65
|
+
* 저장본을 되쓸 때 쓰는 원본 속성. `rendered: false` 라 위 다섯 개와 겹쳐
|
|
66
|
+
* 두 번 나가지 않는다.
|
|
67
|
+
*/
|
|
68
|
+
rawAttrs: {
|
|
69
|
+
default: null,
|
|
70
|
+
rendered: false,
|
|
71
|
+
parseHTML: (element) => readRawAttrs(element)
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
parseHTML() {
|
|
76
|
+
return [{ tag: "tiptap-midibus" }];
|
|
77
|
+
},
|
|
78
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
79
|
+
const raw = node.attrs.rawAttrs;
|
|
80
|
+
/*
|
|
81
|
+
* 읽어 들인 글이면 **원본 그대로**. 속성을 다시 조립하면 순서가 바뀌거나
|
|
82
|
+
* 모르는 속성이 빠져 prod 저장본과 달라진다.
|
|
83
|
+
*/
|
|
84
|
+
if (raw)
|
|
85
|
+
return ["tiptap-midibus", { ...raw }];
|
|
86
|
+
// 여기서 새로 넣은 영상. prod `renderHTML` 과 같은 꼴로 만든다.
|
|
87
|
+
return [
|
|
88
|
+
"tiptap-midibus",
|
|
89
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
90
|
+
"data-bubble-menu": "false"
|
|
91
|
+
})
|
|
92
|
+
];
|
|
93
|
+
},
|
|
94
|
+
addNodeView() {
|
|
95
|
+
return ({ node }) => {
|
|
96
|
+
const videoId = String(node.attrs.id ?? "").trim();
|
|
97
|
+
const startRaw = Number(node.attrs.start ?? 0);
|
|
98
|
+
const start = Number.isFinite(startRaw) ? Math.max(0, Math.floor(startRaw)) : 0;
|
|
99
|
+
const dom = document.createElement("div");
|
|
100
|
+
dom.setAttribute("data-type", "tiptapMidibus");
|
|
101
|
+
dom.setAttribute("data-node-view-wrapper", "");
|
|
102
|
+
dom.style.cssText = "margin:8px 0;position:relative;box-sizing:border-box;max-width:100%;";
|
|
103
|
+
const aspect = document.createElement("div");
|
|
104
|
+
aspect.style.cssText =
|
|
105
|
+
"position:relative;width:100%;padding-top:56.25%;background:#0b1020;border-radius:8px;overflow:hidden;";
|
|
106
|
+
dom.appendChild(aspect);
|
|
107
|
+
const poster = document.createElement("div");
|
|
108
|
+
poster.style.cssText =
|
|
109
|
+
"position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;color:#c7cbd4;";
|
|
110
|
+
aspect.appendChild(poster);
|
|
111
|
+
const label = document.createElement("span");
|
|
112
|
+
label.textContent = "강의 영상";
|
|
113
|
+
label.style.cssText = "font-size:13px;font-weight:600;line-height:1.5;";
|
|
114
|
+
poster.appendChild(label);
|
|
115
|
+
const hint = document.createElement("span");
|
|
116
|
+
hint.textContent = videoId ? videoId : "영상 주소가 비어 있습니다";
|
|
117
|
+
hint.style.cssText = "font-size:12px;line-height:1.5;opacity:0.7;word-break:break-all;";
|
|
118
|
+
poster.appendChild(hint);
|
|
119
|
+
if (videoId) {
|
|
120
|
+
const open = document.createElement("a");
|
|
121
|
+
open.href = `${this.options.playerBaseUrl}/${videoId}?start=${start}&volume=50`;
|
|
122
|
+
open.target = "_blank";
|
|
123
|
+
open.rel = "noopener noreferrer";
|
|
124
|
+
open.textContent = "새 탭에서 보기";
|
|
125
|
+
open.style.cssText =
|
|
126
|
+
"font-size:13px;font-weight:600;color:#fff;background:rgba(255,255,255,0.16);border-radius:6px;padding:6px 14px;text-decoration:none;";
|
|
127
|
+
poster.appendChild(open);
|
|
128
|
+
}
|
|
129
|
+
return { dom };
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
addCommands() {
|
|
133
|
+
return {
|
|
134
|
+
setTiptapMidibus: (attrs) => ({ chain }) => {
|
|
135
|
+
return chain().insertContent({ type: this.name, attrs }).run();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TiptapMidibus.test.d.ts","sourceRoot":"","sources":["../../src/extensions/TiptapMidibus.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
4
|
+
import { TiptapMidibus } from './TiptapMidibus';
|
|
5
|
+
import { sanitizeHtml, transformLegacyHtml } from '../utils/sanitize';
|
|
6
|
+
/**
|
|
7
|
+
* 이 노드의 계약은 **재생이 아니라 보존**이다. 정올 prod 와 lms 는 같은 Datastore 를
|
|
8
|
+
* 쓰므로, lms 에서 열었다 저장하는 것만으로 바이트가 달라지면 prod 쪽 렌더가 깨진다.
|
|
9
|
+
* 그래서 여기 테스트는 전부 "넣은 것이 그대로 나오는가" 를 본다.
|
|
10
|
+
*/
|
|
11
|
+
describe('TiptapMidibus — prod 저장 형식 왕복', () => {
|
|
12
|
+
let editor;
|
|
13
|
+
function createEditor(content) {
|
|
14
|
+
editor = new Editor({
|
|
15
|
+
element: document.createElement('div'),
|
|
16
|
+
extensions: [StarterKit, TiptapMidibus],
|
|
17
|
+
content
|
|
18
|
+
});
|
|
19
|
+
return editor;
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
editor?.destroy();
|
|
23
|
+
});
|
|
24
|
+
/* prod `midibus/index.ts` 의 `renderHTML` 이 실제로 뱉는 꼴. 속성 순서까지 그대로다. */
|
|
25
|
+
const PROD = '<tiptap-midibus id="abc123" start="0" uuid="abc123" width="100%" height="600" data-bubble-menu="false"></tiptap-midibus>';
|
|
26
|
+
it('prod 저장본을 노드로 읽는다', () => {
|
|
27
|
+
createEditor(PROD);
|
|
28
|
+
const doc = editor.getJSON();
|
|
29
|
+
const node = doc.content?.[0];
|
|
30
|
+
expect(node?.type).toBe('tiptapMidibus');
|
|
31
|
+
expect(node?.attrs?.id).toBe('abc123');
|
|
32
|
+
expect(node?.attrs?.uuid).toBe('abc123');
|
|
33
|
+
});
|
|
34
|
+
it('왕복에서 바이트가 그대로다', () => {
|
|
35
|
+
createEditor(PROD);
|
|
36
|
+
expect(editor.getHTML()).toBe(PROD);
|
|
37
|
+
});
|
|
38
|
+
it('앞뒤 본문이 있어도 그대로다', () => {
|
|
39
|
+
const html = `<p>앞</p>${PROD}<p>뒤</p>`;
|
|
40
|
+
createEditor(html);
|
|
41
|
+
expect(editor.getHTML()).toBe(html);
|
|
42
|
+
});
|
|
43
|
+
/*
|
|
44
|
+
* ⚠️ 회귀 표적. 예전엔 이 태그를 아는 노드가 없어 파서가 통째로 버렸고, 실제로
|
|
45
|
+
* 강의영상 글이 lms 에서 `<p>` 하나만 남고 비었다.
|
|
46
|
+
*/
|
|
47
|
+
it('노드가 없으면 사라진다는 것을 함께 박아 둔다', () => {
|
|
48
|
+
const bare = new Editor({
|
|
49
|
+
element: document.createElement('div'),
|
|
50
|
+
extensions: [StarterKit],
|
|
51
|
+
content: PROD
|
|
52
|
+
});
|
|
53
|
+
expect(bare.getHTML()).not.toContain('tiptap-midibus');
|
|
54
|
+
bare.destroy();
|
|
55
|
+
});
|
|
56
|
+
it('모르는 속성도 잃지 않는다', () => {
|
|
57
|
+
const html = '<tiptap-midibus id="v" start="12" uuid="u" width="100%" height="600" data-bubble-menu="false" data-resize-aspect-ratio="1.777"></tiptap-midibus>';
|
|
58
|
+
createEditor(html);
|
|
59
|
+
expect(editor.getHTML()).toBe(html);
|
|
60
|
+
});
|
|
61
|
+
it('속성 순서가 prod 와 달라도 그 순서 그대로 되쓴다', () => {
|
|
62
|
+
const html = '<tiptap-midibus height="400" id="v" uuid="u"></tiptap-midibus>';
|
|
63
|
+
createEditor(html);
|
|
64
|
+
expect(editor.getHTML()).toBe(html);
|
|
65
|
+
});
|
|
66
|
+
it('on* 은 되쓰지 않는다', () => {
|
|
67
|
+
createEditor('<tiptap-midibus id="v" onload="alert(1)"></tiptap-midibus>');
|
|
68
|
+
expect(editor.getHTML()).toBe('<tiptap-midibus id="v"></tiptap-midibus>');
|
|
69
|
+
});
|
|
70
|
+
it('새로 넣으면 prod 와 같은 꼴로 저장된다', () => {
|
|
71
|
+
createEditor('<p></p>');
|
|
72
|
+
editor.commands.setTiptapMidibus({ id: 'new1', start: 30, uuid: 'new1' });
|
|
73
|
+
expect(editor.getHTML()).toContain('<tiptap-midibus id="new1" start="30" uuid="new1" width="100%" height="600" data-bubble-menu="false">');
|
|
74
|
+
});
|
|
75
|
+
it('transformLegacyHtml 은 이 태그를 건드리지 않는다', () => {
|
|
76
|
+
expect(transformLegacyHtml(PROD)).toBe(PROD);
|
|
77
|
+
});
|
|
78
|
+
/*
|
|
79
|
+
* ⚠️ 정적 렌더(`{@html}` + 살균) 경로. 살균의 태그 이름 패턴에 하이픈이 없어서
|
|
80
|
+
* 허용 목록에 `tiptap-midibus` 가 적혀 있는데도 통째로 지워지고 있었다.
|
|
81
|
+
*/
|
|
82
|
+
it('sanitizeHtml 을 통과한다', () => {
|
|
83
|
+
const clean = sanitizeHtml(PROD);
|
|
84
|
+
expect(clean).toContain('<tiptap-midibus');
|
|
85
|
+
expect(clean).toContain('id="abc123"');
|
|
86
|
+
expect(clean).toContain('data-bubble-menu="false"');
|
|
87
|
+
expect(clean).toContain('</tiptap-midibus>');
|
|
88
|
+
});
|
|
89
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ export { FixedDetails } from "./extensions/FixedDetails";
|
|
|
5
5
|
export { FileAttachment } from "./extensions/FileAttachment";
|
|
6
6
|
export type { FileResolver, FileResolveResult } from "./extensions/FileAttachment";
|
|
7
7
|
export { MbusVideo } from "./extensions/MbusVideo";
|
|
8
|
+
export { TiptapMidibus } from "./extensions/TiptapMidibus";
|
|
9
|
+
export type { TiptapMidibusOptions, MidibusRawAttrs } from "./extensions/TiptapMidibus";
|
|
10
|
+
export { LegacyBlock, elementToSpec } from "./extensions/LegacyBlock";
|
|
11
|
+
export type { LegacySpec, LegacySpecElement, LegacyBlockKind } from "./extensions/LegacyBlock";
|
|
8
12
|
export { ResizableImage, normalizeImageWidth } from "./extensions/ResizableImage";
|
|
9
13
|
export { CardBlock } from "./extensions/CardBlock";
|
|
10
14
|
export type { CardBackgroundPrompt, CardBlockOptions } from "./extensions/CardBlock";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAW3E,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AACnF,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAW3E,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AACnF,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAKnD,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AACxF,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACtE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC/F,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAClF,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AACrF,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC5D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGjE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACzF,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,YAAY,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AAahC,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAG5D,YAAY,EACV,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,aAAa,GACd,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,12 @@ export { Indent } from "./extensions/Indent";
|
|
|
14
14
|
export { FixedDetails } from "./extensions/FixedDetails";
|
|
15
15
|
export { FileAttachment } from "./extensions/FileAttachment";
|
|
16
16
|
export { MbusVideo } from "./extensions/MbusVideo";
|
|
17
|
+
/*
|
|
18
|
+
* 정올 prod 저장 형식 보존용. `MbusVideo`(lms 형식 `div[data-mbus-src]`) 와 **다른 노드**다 —
|
|
19
|
+
* 이쪽은 prod 커스텀 태그 `<tiptap-midibus>` 를 그대로 읽고 그대로 되쓴다.
|
|
20
|
+
*/
|
|
21
|
+
export { TiptapMidibus } from "./extensions/TiptapMidibus";
|
|
22
|
+
export { LegacyBlock, elementToSpec } from "./extensions/LegacyBlock";
|
|
17
23
|
export { ResizableImage, normalizeImageWidth } from "./extensions/ResizableImage";
|
|
18
24
|
export { CardBlock } from "./extensions/CardBlock";
|
|
19
25
|
export { Columns } from "./extensions/Columns";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/utils/sanitize.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/utils/sanitize.ts"],"names":[],"mappings":"AAyIA,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBjD;AA6BD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA2ExD;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,SAAM,GAAG,MAAM,CAWrE"}
|
package/dist/utils/sanitize.js
CHANGED
|
@@ -15,7 +15,46 @@ const ALLOWED_TAGS = new Set([
|
|
|
15
15
|
"details", "summary",
|
|
16
16
|
"tiptap-midibus",
|
|
17
17
|
"math-inline", "math-display",
|
|
18
|
+
/*
|
|
19
|
+
* ⚠️ **정올 prod 전용 옛 블록**(`LegacyBlock` 이 원본 그대로 품는 것들). 에디터는
|
|
20
|
+
* 저장 형식을 보존하는데 살균이 태그를 지워 버리면 정적 렌더(`{@html}` + 살균)에서만
|
|
21
|
+
* 조용히 사라져 "에디터엔 보이는데 목록엔 없다" 가 된다.
|
|
22
|
+
* 감싸는 `div.iframe-wrapper`·`div.tiptap-columns` 는 `div` 라 이미 통과한다
|
|
23
|
+
* (`class` 는 `*` 로 전역 허용).
|
|
24
|
+
*/
|
|
25
|
+
"iframe",
|
|
26
|
+
"lite-youtube",
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* `iframe` 을 허용하는 이상 **아무 곳이나 실리게 두지 않는다.** 정올 prod 도 같은 자리에
|
|
30
|
+
* `allowedIframeHostnames: ['jungol.co.kr']` 를 두고 있다(그쪽 `TipTap.svelte`).
|
|
31
|
+
* 여기 없는 호스트는 `src` 만 떨어져 빈 프레임이 된다 — 태그째 지우면 본문 구조가
|
|
32
|
+
* 어긋나므로 값만 뺀다.
|
|
33
|
+
*/
|
|
34
|
+
const ALLOWED_IFRAME_HOSTS = new Set([
|
|
35
|
+
"jungol.co.kr",
|
|
36
|
+
"www.jungol.co.kr",
|
|
37
|
+
"codepass.co.kr",
|
|
38
|
+
"www.codepass.co.kr",
|
|
39
|
+
"play.mbus.tv",
|
|
40
|
+
"youtube.com",
|
|
41
|
+
"www.youtube.com",
|
|
42
|
+
"youtube-nocookie.com",
|
|
43
|
+
"www.youtube-nocookie.com",
|
|
44
|
+
"player.vimeo.com",
|
|
18
45
|
]);
|
|
46
|
+
function isAllowedIframeSrc(value) {
|
|
47
|
+
try {
|
|
48
|
+
// 상대 경로(`/foo`)는 정올 자신을 가리키므로 통과시킨다.
|
|
49
|
+
const url = new URL(value, "https://jungol.co.kr");
|
|
50
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
51
|
+
return false;
|
|
52
|
+
return ALLOWED_IFRAME_HOSTS.has(url.hostname.toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
19
58
|
const ALLOWED_ATTRS = {
|
|
20
59
|
a: new Set(["href", "title", "target", "rel"]),
|
|
21
60
|
img: new Set(["src", "alt", "width", "height"]),
|
|
@@ -54,7 +93,41 @@ const ALLOWED_ATTRS = {
|
|
|
54
93
|
summary: new Set(["data-level"]),
|
|
55
94
|
pre: new Set(["class"]),
|
|
56
95
|
code: new Set(["class"]),
|
|
57
|
-
|
|
96
|
+
/*
|
|
97
|
+
* 정올 prod 강의영상. 다섯 값 뒤에 붙는 것들은 prod `renderHTML` 이 실제로 쓰거나
|
|
98
|
+
* (`data-bubble-menu`) prod 살균이 명시적으로 허용하는 목록 그대로다 — 여기서 빠지면
|
|
99
|
+
* 저장본을 정적으로 그릴 때만 속성이 달아나 왕복이 어긋나 보인다.
|
|
100
|
+
*/
|
|
101
|
+
"tiptap-midibus": new Set([
|
|
102
|
+
"id",
|
|
103
|
+
"start",
|
|
104
|
+
"uuid",
|
|
105
|
+
"width",
|
|
106
|
+
"height",
|
|
107
|
+
"data-bubble-menu",
|
|
108
|
+
"data-hide-bubble-menu",
|
|
109
|
+
"data-resize-handler",
|
|
110
|
+
"data-resize-target",
|
|
111
|
+
"data-resize-min-height",
|
|
112
|
+
"data-resize-max-height",
|
|
113
|
+
"data-resize-aspect-ratio",
|
|
114
|
+
"data-resize-horizontal-align",
|
|
115
|
+
]),
|
|
116
|
+
// 옛 임베드(`div.iframe-wrapper > iframe`). `src` 는 아래 호스트 허용 목록도 함께 탄다.
|
|
117
|
+
iframe: new Set([
|
|
118
|
+
"src",
|
|
119
|
+
"width",
|
|
120
|
+
"height",
|
|
121
|
+
"frameborder",
|
|
122
|
+
"allow",
|
|
123
|
+
"allowfullscreen",
|
|
124
|
+
"loading",
|
|
125
|
+
"title",
|
|
126
|
+
"credentialless",
|
|
127
|
+
"style",
|
|
128
|
+
]),
|
|
129
|
+
// 옛 유튜브 파사드. prod 살균이 허용하는 다섯 개와 같다.
|
|
130
|
+
"lite-youtube": new Set(["videoid", "params", "nocookie", "title", "provider"]),
|
|
58
131
|
"*": new Set(["class", "id"]),
|
|
59
132
|
};
|
|
60
133
|
const SAFE_URL_PATTERN = /^(?:https?:\/\/|\/[\w])/i;
|
|
@@ -62,7 +135,13 @@ export function sanitizeHtml(html) {
|
|
|
62
135
|
return String(html || "")
|
|
63
136
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
64
137
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
65
|
-
|
|
138
|
+
/*
|
|
139
|
+
* ⚠️ 태그 이름에 **하이픈이 들어간다**(`[a-zA-Z0-9-]`). 예전 패턴은 `[a-zA-Z0-9]*` 라
|
|
140
|
+
* `<tiptap-midibus>` 에서 `tiptap` 까지만 집어 허용 목록에 걸리지 못했다 —
|
|
141
|
+
* 즉 `tiptap-midibus`·`math-inline`·`math-display` 세 줄이 **적혀 있는데도 전부
|
|
142
|
+
* 지워지고 있었다.** 커스텀 엘리먼트를 쓰는 이상 이름 규칙이 먼저 맞아야 한다.
|
|
143
|
+
*/
|
|
144
|
+
.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g, (match, tag, attrs) => {
|
|
66
145
|
const lower = tag.toLowerCase();
|
|
67
146
|
if (!ALLOWED_TAGS.has(lower))
|
|
68
147
|
return "";
|
|
@@ -90,6 +169,9 @@ function sanitizeAttributes(tag, attrString) {
|
|
|
90
169
|
if ((name === "href" || name === "src") && !SAFE_URL_PATTERN.test(value)) {
|
|
91
170
|
continue;
|
|
92
171
|
}
|
|
172
|
+
if (tag === "iframe" && name === "src" && !isAllowedIframeSrc(value)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
93
175
|
result.push(` ${name}="${escapeHtml(value)}"`);
|
|
94
176
|
}
|
|
95
177
|
return result.join("");
|
|
@@ -144,10 +226,13 @@ export function transformLegacyHtml(html) {
|
|
|
144
226
|
const name = text.trim() || "\uD30C\uC77C";
|
|
145
227
|
return `<div data-file-id="${id}" data-file-name="${name}"></div>`;
|
|
146
228
|
})
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
229
|
+
/*
|
|
230
|
+
* ⚠️ **옛 단(`div.tiptap-columns`)은 여기서 바꾸지 않는다.** 예전엔
|
|
231
|
+
* `data-type="columns"` 로 갈아 끼워 `Columns` 가 편집할 수 있게 했는데,
|
|
232
|
+
* 정올 prod 와 lms 는 **같은 Datastore 를 공유**한다 — 그렇게 저장하면 이번엔
|
|
233
|
+
* prod 에디터가 그 형식을 몰라 단이 통째로 사라진다. 손해를 반대로 옮길 뿐이다.
|
|
234
|
+
* 지금은 `LegacyBlock` 이 원본 마크업 그대로 품고 되쓴다(우선순위 100).
|
|
235
|
+
*/
|
|
151
236
|
// <tiptap-upload-skeleton ...> 제거
|
|
152
237
|
.replace(/<tiptap-upload-skeleton[^>]*>(?:<\/tiptap-upload-skeleton>)?/gi, ""));
|
|
153
238
|
}
|
|
@@ -49,13 +49,16 @@ describe('transformLegacyHtml', () => {
|
|
|
49
49
|
expect(result).toContain('data-pdf-src="https://example.com/doc.pdf"');
|
|
50
50
|
expect(result).toContain('data-pdf-name="doc.pdf"');
|
|
51
51
|
});
|
|
52
|
-
|
|
52
|
+
/*
|
|
53
|
+
* ⚠️ **예전엔 여기서 `data-type="columns"` 로 갈아 끼웠다.** 그러면 lms 에서는 단이
|
|
54
|
+
* 편집 가능해지지만, 정올 prod 와 lms 는 **같은 Datastore 를 공유**하므로 그렇게 저장한
|
|
55
|
+
* 순간 이번엔 prod 에디터가 그 형식을 몰라 단이 통째로 사라진다 — 손해를 반대편으로
|
|
56
|
+
* 옮길 뿐이었다. 지금은 `LegacyBlock` 이 원본 마크업 그대로 품고 되쓴다.
|
|
57
|
+
*/
|
|
58
|
+
it('leaves <div class="tiptap-columns"> untouched for LegacyBlock', () => {
|
|
53
59
|
const input = '<div class="tiptap-columns"><div class="tiptap-column">A</div></div>';
|
|
54
60
|
const result = transformLegacyHtml(input);
|
|
55
|
-
expect(result).
|
|
56
|
-
expect(result).toContain('data-type="column"');
|
|
57
|
-
expect(result).not.toContain('tiptap-columns');
|
|
58
|
-
expect(result).not.toContain('tiptap-column');
|
|
61
|
+
expect(result).toBe(input);
|
|
59
62
|
});
|
|
60
63
|
it('removes <tiptap-upload-skeleton>', () => {
|
|
61
64
|
const input = '<p>Before</p><tiptap-upload-skeleton data-id="x"></tiptap-upload-skeleton><p>After</p>';
|