@teriusu/rich-editor 0.10.8 → 0.11.0
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/BubbleToolbar.svelte +40 -36
- package/dist/components/BubbleToolbar.svelte.d.ts +3 -0
- package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -1
- package/dist/components/EmojiSuggestionMenu.svelte +291 -0
- package/dist/components/EmojiSuggestionMenu.svelte.d.ts +9 -0
- package/dist/components/EmojiSuggestionMenu.svelte.d.ts.map +1 -0
- package/dist/components/FixedToolbar.svelte +53 -47
- package/dist/components/FixedToolbar.svelte.d.ts +3 -0
- package/dist/components/FixedToolbar.svelte.d.ts.map +1 -1
- package/dist/components/FloatingHelper.svelte +219 -0
- package/dist/components/FloatingHelper.svelte.d.ts +14 -0
- package/dist/components/FloatingHelper.svelte.d.ts.map +1 -0
- package/dist/components/InputModal.svelte +7 -3
- package/dist/components/InputModal.svelte.d.ts +3 -0
- package/dist/components/InputModal.svelte.d.ts.map +1 -1
- package/dist/components/MathModal.svelte +33 -29
- package/dist/components/MathModal.svelte.d.ts +3 -0
- package/dist/components/MathModal.svelte.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.svelte +7 -3
- package/dist/components/MediaPickerModal.svelte.d.ts +3 -0
- package/dist/components/MediaPickerModal.svelte.d.ts.map +1 -1
- package/dist/components/SlashCommandMenu.svelte +88 -72
- package/dist/components/SlashCommandMenu.svelte.d.ts +3 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -1
- package/dist/components/TableBubbleMenu.svelte +41 -33
- package/dist/components/TableBubbleMenu.svelte.d.ts +3 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -1
- package/dist/components/TipTapEditor.svelte +239 -127
- package/dist/components/TipTapEditor.svelte.d.ts +7 -0
- package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
- package/dist/extensions/CardBlock.d.ts.map +1 -1
- package/dist/extensions/CardBlock.js +6 -4
- package/dist/extensions/FileAttachment.d.ts.map +1 -1
- package/dist/extensions/FileAttachment.js +2 -1
- package/dist/extensions/FixedDetails.d.ts.map +1 -1
- package/dist/extensions/FixedDetails.js +5 -3
- package/dist/extensions/LegacyBlock.d.ts.map +1 -1
- package/dist/extensions/LegacyBlock.js +8 -6
- package/dist/extensions/Math.d.ts.map +1 -1
- package/dist/extensions/Math.js +5 -2
- package/dist/extensions/MbusVideo.d.ts.map +1 -1
- package/dist/extensions/MbusVideo.js +73 -9
- package/dist/extensions/MediaToolbar.d.ts +41 -0
- package/dist/extensions/MediaToolbar.d.ts.map +1 -0
- package/dist/extensions/MediaToolbar.js +196 -0
- package/dist/extensions/MediaToolbar.test.d.ts +2 -0
- package/dist/extensions/MediaToolbar.test.d.ts.map +1 -0
- package/dist/extensions/MediaToolbar.test.js +231 -0
- package/dist/extensions/OrderedListMarker.d.ts +2 -0
- package/dist/extensions/OrderedListMarker.d.ts.map +1 -0
- package/dist/extensions/OrderedListMarker.js +114 -0
- package/dist/extensions/OrderedListMarker.test.d.ts +2 -0
- package/dist/extensions/OrderedListMarker.test.d.ts.map +1 -0
- package/dist/extensions/OrderedListMarker.test.js +176 -0
- package/dist/extensions/PdfBlock.d.ts.map +1 -1
- package/dist/extensions/PdfBlock.js +25 -23
- package/dist/extensions/ResizableImage.d.ts.map +1 -1
- package/dist/extensions/ResizableImage.js +20 -1
- package/dist/extensions/TableKeymap.d.ts +16 -0
- package/dist/extensions/TableKeymap.d.ts.map +1 -0
- package/dist/extensions/TableKeymap.js +97 -0
- package/dist/extensions/TableKeymap.test.d.ts +2 -0
- package/dist/extensions/TableKeymap.test.d.ts.map +1 -0
- package/dist/extensions/TableKeymap.test.js +147 -0
- package/dist/extensions/TabsBlock.d.ts.map +1 -1
- package/dist/extensions/TabsBlock.js +12 -9
- package/dist/extensions/TiptapMidibus.d.ts.map +1 -1
- package/dist/extensions/TiptapMidibus.js +60 -6
- package/dist/extensions/UploadSkeleton.d.ts +61 -0
- package/dist/extensions/UploadSkeleton.d.ts.map +1 -0
- package/dist/extensions/UploadSkeleton.js +233 -0
- package/dist/extensions/UploadSkeleton.test.d.ts +2 -0
- package/dist/extensions/UploadSkeleton.test.d.ts.map +1 -0
- package/dist/extensions/UploadSkeleton.test.js +128 -0
- package/dist/extensions/VideoEmbed.d.ts +8 -0
- package/dist/extensions/VideoEmbed.d.ts.map +1 -1
- package/dist/extensions/VideoEmbed.js +226 -45
- package/dist/extensions/VideoEmbed.test.js +131 -2
- package/dist/i18n/en.d.ts +3 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +191 -0
- package/dist/i18n/es.d.ts +3 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +191 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +109 -0
- package/dist/i18n/index.test.d.ts +2 -0
- package/dist/i18n/index.test.d.ts.map +1 -0
- package/dist/i18n/index.test.js +173 -0
- package/dist/i18n/ja.d.ts +3 -0
- package/dist/i18n/ja.d.ts.map +1 -0
- package/dist/i18n/ja.js +191 -0
- package/dist/i18n/ko.d.ts +198 -0
- package/dist/i18n/ko.d.ts.map +1 -0
- package/dist/i18n/ko.js +215 -0
- package/dist/i18n/zh-hans.d.ts +3 -0
- package/dist/i18n/zh-hans.d.ts.map +1 -0
- package/dist/i18n/zh-hans.js +191 -0
- package/dist/i18n/zh-hant.d.ts +3 -0
- package/dist/i18n/zh-hant.d.ts.map +1 -0
- package/dist/i18n/zh-hant.js +192 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/static/highlight.d.ts +13 -0
- package/dist/static/highlight.d.ts.map +1 -0
- package/dist/static/highlight.js +99 -0
- package/dist/static/index.d.ts +33 -0
- package/dist/static/index.d.ts.map +1 -0
- package/dist/static/index.js +367 -0
- package/dist/static/index.test.d.ts +2 -0
- package/dist/static/index.test.d.ts.map +1 -0
- package/dist/static/index.test.js +194 -0
- package/dist/static/tokenizer.d.ts +52 -0
- package/dist/static/tokenizer.d.ts.map +1 -0
- package/dist/static/tokenizer.js +289 -0
- package/dist/styles/editor.css +1 -1
- package/dist/utils/emoji-data.d.ts +15 -0
- package/dist/utils/emoji-suggestion.d.ts +48 -0
- package/dist/utils/emoji-suggestion.d.ts.map +1 -0
- package/dist/utils/emoji-suggestion.js +51 -0
- package/dist/utils/emoji-suggestion.test.d.ts +2 -0
- package/dist/utils/emoji-suggestion.test.d.ts.map +1 -0
- package/dist/utils/emoji-suggestion.test.js +117 -0
- package/dist/utils/floating-helper.d.ts +60 -0
- package/dist/utils/floating-helper.d.ts.map +1 -0
- package/dist/utils/floating-helper.js +63 -0
- package/dist/utils/floating-helper.test.d.ts +2 -0
- package/dist/utils/floating-helper.test.d.ts.map +1 -0
- package/dist/utils/floating-helper.test.js +141 -0
- package/dist/utils/guard-vscode-paste.d.ts +19 -0
- package/dist/utils/guard-vscode-paste.d.ts.map +1 -0
- package/dist/utils/guard-vscode-paste.js +46 -0
- package/dist/utils/guard-vscode-paste.test.d.ts +2 -0
- package/dist/utils/guard-vscode-paste.test.d.ts.map +1 -0
- package/dist/utils/guard-vscode-paste.test.js +86 -0
- package/dist/utils/media-size.d.ts +29 -0
- package/dist/utils/media-size.d.ts.map +1 -0
- package/dist/utils/media-size.js +90 -0
- package/dist/utils/resize.d.ts +12 -0
- package/dist/utils/resize.d.ts.map +1 -1
- package/dist/utils/resize.js +5 -5
- package/dist/utils/sanitize.d.ts +2 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +28 -3
- package/package.json +8 -1
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* 다뤄진다. 저장 형식이 곧 계약이라, 담기는 것이 달라지면 이름도 달라야 한다.
|
|
8
8
|
*/
|
|
9
9
|
import { Node, mergeAttributes } from "@tiptap/core";
|
|
10
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
10
11
|
import { attachResize } from "../utils/resize";
|
|
12
|
+
import { getEditorTranslator } from "../i18n";
|
|
13
|
+
import { mediaRatioCss, normalizeMediaAlign, normalizeMediaHeight, normalizeMediaRatio } from "../utils/media-size";
|
|
11
14
|
/** `1m30s` · `90` 처럼 적히는 유튜브 타임스탬프를 초로 바꾼다. */
|
|
12
15
|
function parseTimestamp(raw) {
|
|
13
16
|
if (!raw)
|
|
@@ -19,6 +22,71 @@ function parseTimestamp(raw) {
|
|
|
19
22
|
return null;
|
|
20
23
|
return Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0);
|
|
21
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* 저장된 `data-video-src` 를 iframe `src` 로 쓰기 전의 마지막 관문 — http(s) 만 통과한다.
|
|
27
|
+
*
|
|
28
|
+
* 살균기(`sanitize.ts`)의 URL 검사는 `href`·`src` 속성만 보므로 `data-video-src` 에
|
|
29
|
+
* `javascript:` 가 실려 있으면 그대로 통과해 편집 모드 iframe 까지 닿는다. 새로 만드는
|
|
30
|
+
* 경로(붙여넣기·명령)는 전부 임베드 변환기를 거쳐 안전하지만, **저장본은 출처를 믿을 수
|
|
31
|
+
* 없다** — 읽는 쪽에서 거른다.
|
|
32
|
+
*/
|
|
33
|
+
function safeVideoSrc(raw) {
|
|
34
|
+
if (typeof raw !== "string")
|
|
35
|
+
return null;
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
return /^https?:\/\//i.test(trimmed) ? trimmed : null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 유튜브 주소면 임베드 주소를, 아니면 null 을 돌려준다.
|
|
41
|
+
*
|
|
42
|
+
* `toEmbedUrl` 의 유튜브 갈래를 떼어 낸 것 — 붙여넣기 자동 임베드(아래
|
|
43
|
+
* `addProseMirrorPlugins`)가 **유튜브 판별기로도** 써야 해서다. "변환 결과가 달라졌는가"
|
|
44
|
+
* 로 어림하면 이미 임베드 꼴인 주소(`youtube.com/embed/ID`)를 놓친다.
|
|
45
|
+
*/
|
|
46
|
+
export function youTubeEmbedUrl(raw) {
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
if (!trimmed)
|
|
49
|
+
return null;
|
|
50
|
+
let u;
|
|
51
|
+
try {
|
|
52
|
+
u = new URL(trimmed);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const host = u.hostname.replace(/^www\./, "");
|
|
58
|
+
const isYouTube = host === "youtube.com" ||
|
|
59
|
+
host === "m.youtube.com" ||
|
|
60
|
+
host === "music.youtube.com" ||
|
|
61
|
+
host === "youtube-nocookie.com" ||
|
|
62
|
+
host === "youtu.be";
|
|
63
|
+
if (!isYouTube)
|
|
64
|
+
return null;
|
|
65
|
+
let id = "";
|
|
66
|
+
if (host === "youtu.be") {
|
|
67
|
+
id = u.pathname.split("/").filter(Boolean)[0] || "";
|
|
68
|
+
}
|
|
69
|
+
else if (u.pathname === "/watch") {
|
|
70
|
+
id = u.searchParams.get("v") || "";
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// /embed/ID · /shorts/ID · /live/ID · /v/ID
|
|
74
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
75
|
+
if (["embed", "shorts", "live", "v"].includes(parts[0]))
|
|
76
|
+
id = parts[1] || "";
|
|
77
|
+
}
|
|
78
|
+
if (!id)
|
|
79
|
+
return null;
|
|
80
|
+
const embed = new URL(`https://www.youtube.com/embed/${id}`);
|
|
81
|
+
const start = parseTimestamp(u.searchParams.get("t") || u.searchParams.get("start"));
|
|
82
|
+
if (start)
|
|
83
|
+
embed.searchParams.set("start", String(start));
|
|
84
|
+
// 재생목록 안의 영상이면 목록을 유지한다.
|
|
85
|
+
const list = u.searchParams.get("list");
|
|
86
|
+
if (list)
|
|
87
|
+
embed.searchParams.set("list", list);
|
|
88
|
+
return embed.toString();
|
|
89
|
+
}
|
|
22
90
|
/**
|
|
23
91
|
* 붙여넣은 주소를 **iframe 에 넣을 수 있는 주소**로 바꾼다.
|
|
24
92
|
*
|
|
@@ -34,6 +102,10 @@ export function toEmbedUrl(raw) {
|
|
|
34
102
|
const trimmed = raw.trim();
|
|
35
103
|
if (!trimmed)
|
|
36
104
|
return trimmed;
|
|
105
|
+
// ── 유튜브 ──
|
|
106
|
+
const youtube = youTubeEmbedUrl(trimmed);
|
|
107
|
+
if (youtube)
|
|
108
|
+
return youtube;
|
|
37
109
|
let u;
|
|
38
110
|
try {
|
|
39
111
|
u = new URL(trimmed);
|
|
@@ -42,38 +114,6 @@ export function toEmbedUrl(raw) {
|
|
|
42
114
|
return trimmed;
|
|
43
115
|
}
|
|
44
116
|
const host = u.hostname.replace(/^www\./, "");
|
|
45
|
-
// ── 유튜브 ──
|
|
46
|
-
const isYouTube = host === "youtube.com" ||
|
|
47
|
-
host === "m.youtube.com" ||
|
|
48
|
-
host === "music.youtube.com" ||
|
|
49
|
-
host === "youtube-nocookie.com" ||
|
|
50
|
-
host === "youtu.be";
|
|
51
|
-
if (isYouTube) {
|
|
52
|
-
let id = "";
|
|
53
|
-
if (host === "youtu.be") {
|
|
54
|
-
id = u.pathname.split("/").filter(Boolean)[0] || "";
|
|
55
|
-
}
|
|
56
|
-
else if (u.pathname === "/watch") {
|
|
57
|
-
id = u.searchParams.get("v") || "";
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
// /embed/ID · /shorts/ID · /live/ID · /v/ID
|
|
61
|
-
const parts = u.pathname.split("/").filter(Boolean);
|
|
62
|
-
if (["embed", "shorts", "live", "v"].includes(parts[0]))
|
|
63
|
-
id = parts[1] || "";
|
|
64
|
-
}
|
|
65
|
-
if (!id)
|
|
66
|
-
return trimmed;
|
|
67
|
-
const embed = new URL(`https://www.youtube.com/embed/${id}`);
|
|
68
|
-
const start = parseTimestamp(u.searchParams.get("t") || u.searchParams.get("start"));
|
|
69
|
-
if (start)
|
|
70
|
-
embed.searchParams.set("start", String(start));
|
|
71
|
-
// 재생목록 안의 영상이면 목록을 유지한다.
|
|
72
|
-
const list = u.searchParams.get("list");
|
|
73
|
-
if (list)
|
|
74
|
-
embed.searchParams.set("list", list);
|
|
75
|
-
return embed.toString();
|
|
76
|
-
}
|
|
77
117
|
// ── Vimeo ──
|
|
78
118
|
if (host === "vimeo.com" || host === "player.vimeo.com") {
|
|
79
119
|
if (host === "player.vimeo.com")
|
|
@@ -112,10 +152,20 @@ export const VideoEmbed = Node.create({
|
|
|
112
152
|
addOptions() {
|
|
113
153
|
return { HTMLAttributes: {} };
|
|
114
154
|
},
|
|
155
|
+
/*
|
|
156
|
+
* 크기·정렬은 전부 **`data-*` 속성**으로 저장한다(사고 이력: `style` 로 저장하면
|
|
157
|
+
* 살균·정적 렌더에서 지워져 크기가 튄다). `style` 은 폭·정렬을 **거울처럼 한 벌 더**
|
|
158
|
+
* 적을 뿐이고, 읽을 때의 정본은 언제나 `data-*` 쪽이다.
|
|
159
|
+
* 높이·비율은 배타다 — 높이가 있으면 고정 px, 없고 비율이 있으면 비율 박스,
|
|
160
|
+
* 둘 다 없으면 기본 16:9. (main `buildResizeAttrs` 의 규칙 재작성.)
|
|
161
|
+
*/
|
|
115
162
|
addAttributes() {
|
|
116
163
|
return {
|
|
117
164
|
src: { default: null },
|
|
118
|
-
width: { default: null }
|
|
165
|
+
width: { default: null },
|
|
166
|
+
height: { default: null },
|
|
167
|
+
ratio: { default: null },
|
|
168
|
+
align: { default: null }
|
|
119
169
|
};
|
|
120
170
|
},
|
|
121
171
|
parseHTML() {
|
|
@@ -124,9 +174,16 @@ export const VideoEmbed = Node.create({
|
|
|
124
174
|
tag: "div[data-video-src]",
|
|
125
175
|
getAttrs: (dom) => {
|
|
126
176
|
const el = dom;
|
|
177
|
+
const src = safeVideoSrc(el.getAttribute("data-video-src"));
|
|
178
|
+
// 스킴이 수상한 저장본은 영상 노드로 받지 않는다 — 빈 껍데기를 남기느니 버린다.
|
|
179
|
+
if (!src)
|
|
180
|
+
return false;
|
|
127
181
|
return {
|
|
128
|
-
src
|
|
129
|
-
width: el.getAttribute("data-video-width") || el.style?.width || null
|
|
182
|
+
src,
|
|
183
|
+
width: el.getAttribute("data-video-width") || el.style?.width || null,
|
|
184
|
+
height: normalizeMediaHeight(el.getAttribute("data-video-height")),
|
|
185
|
+
ratio: normalizeMediaRatio(el.getAttribute("data-video-ratio")),
|
|
186
|
+
align: normalizeMediaAlign(el.getAttribute("data-video-align"))
|
|
130
187
|
};
|
|
131
188
|
}
|
|
132
189
|
}
|
|
@@ -136,28 +193,68 @@ export const VideoEmbed = Node.create({
|
|
|
136
193
|
const attrs = {
|
|
137
194
|
"data-video-src": HTMLAttributes.src
|
|
138
195
|
};
|
|
196
|
+
const styles = [];
|
|
139
197
|
if (HTMLAttributes.width) {
|
|
140
198
|
attrs["data-video-width"] = HTMLAttributes.width;
|
|
141
|
-
|
|
199
|
+
styles.push(`width: ${HTMLAttributes.width}`);
|
|
142
200
|
}
|
|
201
|
+
const height = normalizeMediaHeight(HTMLAttributes.height);
|
|
202
|
+
if (height != null)
|
|
203
|
+
attrs["data-video-height"] = String(height);
|
|
204
|
+
const ratio = normalizeMediaRatio(HTMLAttributes.ratio);
|
|
205
|
+
if (height == null && ratio)
|
|
206
|
+
attrs["data-video-ratio"] = ratio;
|
|
207
|
+
const align = normalizeMediaAlign(HTMLAttributes.align);
|
|
208
|
+
if (align) {
|
|
209
|
+
attrs["data-video-align"] = align;
|
|
210
|
+
if (align === "center")
|
|
211
|
+
styles.push("margin-left: auto", "margin-right: auto");
|
|
212
|
+
else if (align === "right")
|
|
213
|
+
styles.push("margin-left: auto");
|
|
214
|
+
}
|
|
215
|
+
if (styles.length)
|
|
216
|
+
attrs["style"] = styles.join("; ");
|
|
143
217
|
return ["div", mergeAttributes(this.options.HTMLAttributes, attrs)];
|
|
144
218
|
},
|
|
145
219
|
addNodeView() {
|
|
146
220
|
return ({ node, editor, getPos }) => {
|
|
221
|
+
const t = getEditorTranslator(editor);
|
|
147
222
|
// 리사이즈가 attrs 를 되쓰므로 **항상 최신 노드**여야 한다(mbus 의 stale 버그와 같은 자리).
|
|
148
223
|
let currentNode = node;
|
|
149
224
|
let detachResize = null;
|
|
225
|
+
let detachHeightResize = null;
|
|
150
226
|
const dom = document.createElement("div");
|
|
151
227
|
dom.setAttribute("data-type", "videoEmbed");
|
|
152
228
|
dom.setAttribute("data-node-view-wrapper", "");
|
|
153
229
|
dom.style.cssText = "margin:8px 0;position:relative;box-sizing:border-box;max-width:100%;";
|
|
154
|
-
|
|
155
|
-
|
|
230
|
+
/*
|
|
231
|
+
* 비율 박스. 예전의 `padding-top:56.25%` 대신 CSS `aspect-ratio` 를 쓴다 —
|
|
232
|
+
* `height` 가 지정되면 `aspect-ratio` 는 저절로 비켜서므로(auto 축이 없어진다)
|
|
233
|
+
* 높이 드래그가 인라인 `height` 하나로 그대로 먹는다. padding 방식은 높이를
|
|
234
|
+
* 얹으면 패딩 위에 **더해져** 박스가 두 배로 자란다.
|
|
235
|
+
*/
|
|
156
236
|
const aspect = document.createElement("div");
|
|
157
237
|
aspect.style.cssText =
|
|
158
|
-
"position:relative;width:100%;
|
|
238
|
+
"position:relative;width:100%;aspect-ratio:16 / 9;background:#0b1020;border-radius:8px;overflow:hidden;";
|
|
159
239
|
dom.appendChild(aspect);
|
|
160
|
-
const
|
|
240
|
+
const applyLayout = (attrs) => {
|
|
241
|
+
dom.style.width = typeof attrs.width === "string" && attrs.width ? attrs.width : "";
|
|
242
|
+
const height = normalizeMediaHeight(attrs.height);
|
|
243
|
+
if (height != null) {
|
|
244
|
+
aspect.style.height = `${height}px`;
|
|
245
|
+
aspect.style.removeProperty("aspect-ratio");
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
aspect.style.removeProperty("height");
|
|
249
|
+
aspect.style.aspectRatio = mediaRatioCss(attrs.ratio) ?? "16 / 9";
|
|
250
|
+
}
|
|
251
|
+
const align = normalizeMediaAlign(attrs.align);
|
|
252
|
+
dom.style.marginLeft = align === "center" || align === "right" ? "auto" : "";
|
|
253
|
+
dom.style.marginRight = align === "center" ? "auto" : "";
|
|
254
|
+
};
|
|
255
|
+
applyLayout(node.attrs);
|
|
256
|
+
// parseHTML 을 안 거친 경로(JSON 삽입·명령 오용)까지 막는 이중 관문.
|
|
257
|
+
const src = safeVideoSrc(node.attrs.src);
|
|
161
258
|
if (src && canEmbedCrossOrigin()) {
|
|
162
259
|
const iframe = document.createElement("iframe");
|
|
163
260
|
iframe.src = src;
|
|
@@ -182,14 +279,14 @@ export const VideoEmbed = Node.create({
|
|
|
182
279
|
poster.style.cssText =
|
|
183
280
|
"position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px;text-align:center;color:#c7cbd4;";
|
|
184
281
|
const label = document.createElement("span");
|
|
185
|
-
label.textContent = "
|
|
282
|
+
label.textContent = t("videoCannotEmbed");
|
|
186
283
|
label.style.cssText = "font-size:13px;line-height:1.5;";
|
|
187
284
|
poster.appendChild(label);
|
|
188
285
|
const open = document.createElement("a");
|
|
189
286
|
open.href = src;
|
|
190
287
|
open.target = "_blank";
|
|
191
288
|
open.rel = "noopener noreferrer";
|
|
192
|
-
open.textContent = "
|
|
289
|
+
open.textContent = t("openInNewTab");
|
|
193
290
|
open.style.cssText =
|
|
194
291
|
"font-size:13px;font-weight:600;color:#fff;background:rgba(255,255,255,0.16);border-radius:6px;padding:6px 14px;text-decoration:none;";
|
|
195
292
|
poster.appendChild(open);
|
|
@@ -214,7 +311,29 @@ export const VideoEmbed = Node.create({
|
|
|
214
311
|
getPos: () => (typeof getPos === "function" ? getPos() : undefined),
|
|
215
312
|
getNode: () => currentNode,
|
|
216
313
|
axis: "x",
|
|
217
|
-
label: "
|
|
314
|
+
label: t("videoResizeWidth")
|
|
315
|
+
});
|
|
316
|
+
/*
|
|
317
|
+
* 높이 드래그. 크기는 비율 박스가 먹고(고정 px 저장), 손잡이는 래퍼에
|
|
318
|
+
* 산다 — 박스가 `overflow:hidden` 이라 밖 12px 손잡이가 잘리기 때문.
|
|
319
|
+
* 수동 드래그는 비율 프리셋을 푼다(main 과 같은 규칙).
|
|
320
|
+
*/
|
|
321
|
+
detachHeightResize = attachResize({
|
|
322
|
+
dom: aspect,
|
|
323
|
+
handleParent: dom,
|
|
324
|
+
editor,
|
|
325
|
+
getPos: () => (typeof getPos === "function" ? getPos() : undefined),
|
|
326
|
+
getNode: () => currentNode,
|
|
327
|
+
axis: "y",
|
|
328
|
+
attr: "height",
|
|
329
|
+
min: 120,
|
|
330
|
+
max: 1600,
|
|
331
|
+
label: t("videoResizeHeight"),
|
|
332
|
+
buildAttrs: (current, value) => ({
|
|
333
|
+
...current.attrs,
|
|
334
|
+
height: String(Math.round(value)),
|
|
335
|
+
ratio: null
|
|
336
|
+
})
|
|
218
337
|
});
|
|
219
338
|
}
|
|
220
339
|
return {
|
|
@@ -222,17 +341,79 @@ export const VideoEmbed = Node.create({
|
|
|
222
341
|
update: (updated) => {
|
|
223
342
|
if (updated.type !== currentNode.type)
|
|
224
343
|
return false;
|
|
225
|
-
|
|
226
|
-
dom.style.width = w || "";
|
|
344
|
+
applyLayout(updated.attrs);
|
|
227
345
|
currentNode = updated;
|
|
228
346
|
return true;
|
|
229
347
|
},
|
|
230
348
|
destroy: () => {
|
|
231
349
|
detachResize?.();
|
|
350
|
+
detachHeightResize?.();
|
|
232
351
|
}
|
|
233
352
|
};
|
|
234
353
|
};
|
|
235
354
|
},
|
|
355
|
+
addProseMirrorPlugins() {
|
|
356
|
+
return [
|
|
357
|
+
/*
|
|
358
|
+
* 빈 문단에 유튜브 주소를 붙여넣으면 **즉시 임베드로** 바꾼다. 플레인 텍스트와
|
|
359
|
+
* 링크 마크(href) 둘 다 받는다. (main 정올 `plugin/youtube.ts` 의
|
|
360
|
+
* `handlePasteVideoURL` 참조 — 로직 재작성.)
|
|
361
|
+
*
|
|
362
|
+
* ⚠️ **유튜브만** 자동 변환한다. 아무 주소나 임베드로 바꾸면 일반 링크
|
|
363
|
+
* 붙여넣기가 전부 잡아먹히고, 모르는 주소는 iframe 에서 빈 칸이 된다
|
|
364
|
+
* (`toEmbedUrl` 주석). 다른 서비스는 지금처럼 툴바·슬래시로 넣는다.
|
|
365
|
+
*/
|
|
366
|
+
new Plugin({
|
|
367
|
+
key: new PluginKey("videoEmbedPaste"),
|
|
368
|
+
props: {
|
|
369
|
+
handlePaste: (view, _event, slice) => {
|
|
370
|
+
if (!this.editor.isEditable)
|
|
371
|
+
return false;
|
|
372
|
+
if (slice.content.childCount !== 1)
|
|
373
|
+
return false;
|
|
374
|
+
const { selection } = view.state;
|
|
375
|
+
if (!selection.empty)
|
|
376
|
+
return false;
|
|
377
|
+
/* 빈 문단에서만 — 글 쓰던 자리·코드블록에는 끼어들지 않는다. */
|
|
378
|
+
const $head = selection.$head;
|
|
379
|
+
const parent = $head.node();
|
|
380
|
+
if (parent.type.name !== "paragraph" || parent.content.size > 0)
|
|
381
|
+
return false;
|
|
382
|
+
let embed = youTubeEmbedUrl(slice.content.child(0).textContent);
|
|
383
|
+
/* 플레인 텍스트가 아니면 링크 마크의 href 도 본다. */
|
|
384
|
+
if (!embed) {
|
|
385
|
+
slice.content.descendants((node) => {
|
|
386
|
+
if (embed)
|
|
387
|
+
return false;
|
|
388
|
+
for (const mark of node.marks) {
|
|
389
|
+
const href = mark.attrs.href;
|
|
390
|
+
if (!href)
|
|
391
|
+
continue;
|
|
392
|
+
const fromHref = youTubeEmbedUrl(href);
|
|
393
|
+
if (fromHref) {
|
|
394
|
+
embed = fromHref;
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (!embed)
|
|
402
|
+
return false;
|
|
403
|
+
/* 빈 문단은 그대로 두고 그 앞에 심는다 — 커서가 이어서 쓸 자리로 남는다. */
|
|
404
|
+
this.editor
|
|
405
|
+
.chain()
|
|
406
|
+
.insertContentAt($head.before(), {
|
|
407
|
+
type: this.name,
|
|
408
|
+
attrs: { src: embed }
|
|
409
|
+
})
|
|
410
|
+
.run();
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
];
|
|
416
|
+
},
|
|
236
417
|
addCommands() {
|
|
237
418
|
return {
|
|
238
419
|
setVideoEmbed: (attrs) => ({ chain }) => {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { Editor } from "@tiptap/core";
|
|
3
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
4
|
+
import { Slice, Fragment } from "@tiptap/pm/model";
|
|
5
|
+
import { toEmbedUrl, youTubeEmbedUrl, VideoEmbed } from "./VideoEmbed";
|
|
3
6
|
/*
|
|
4
7
|
* 이 변환이 이 확장의 전부다. 틀리면 유튜브가 `X-Frame-Options` 로 거부해 **빈 칸**만
|
|
5
8
|
* 남고, 화면에는 아무 단서도 안 나온다. 그래서 사람들이 실제로 붙여넣는 꼴을 모아 둔다.
|
|
@@ -55,3 +58,129 @@ describe("toEmbedUrl", () => {
|
|
|
55
58
|
expect(toEmbedUrl(weird)).toBe(weird);
|
|
56
59
|
});
|
|
57
60
|
});
|
|
61
|
+
describe("youTubeEmbedUrl", () => {
|
|
62
|
+
it("유튜브 주소는 임베드 주소를 돌려준다", () => {
|
|
63
|
+
expect(youTubeEmbedUrl("https://youtu.be/dQw4w9WgXcQ")).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
|
64
|
+
// 이미 임베드 꼴이어도 유튜브로 알아본다 — "변환됐는가" 어림이 안 되는 이유
|
|
65
|
+
expect(youTubeEmbedUrl("https://www.youtube.com/embed/abc12345678")).toBe("https://www.youtube.com/embed/abc12345678");
|
|
66
|
+
});
|
|
67
|
+
it("유튜브가 아니면 null — vimeo·일반 링크는 자동 임베드 대상이 아니다", () => {
|
|
68
|
+
expect(youTubeEmbedUrl("https://vimeo.com/123456789")).toBeNull();
|
|
69
|
+
expect(youTubeEmbedUrl("https://example.com/watch?v=abc12345678")).toBeNull();
|
|
70
|
+
expect(youTubeEmbedUrl("그냥 텍스트")).toBeNull();
|
|
71
|
+
expect(youTubeEmbedUrl("")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe("유튜브 주소 붙여넣기 → 즉시 임베드", () => {
|
|
75
|
+
let editor;
|
|
76
|
+
function createEditor(content = "<p></p>") {
|
|
77
|
+
editor = new Editor({
|
|
78
|
+
element: document.createElement("div"),
|
|
79
|
+
extensions: [StarterKit, VideoEmbed],
|
|
80
|
+
content
|
|
81
|
+
});
|
|
82
|
+
return editor;
|
|
83
|
+
}
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
editor?.destroy();
|
|
86
|
+
});
|
|
87
|
+
/** 슬라이스 하나를 실제 플러그인 사슬(someProp)로 붙여넣는다. */
|
|
88
|
+
function paste(fragment) {
|
|
89
|
+
const slice = new Slice(fragment, 0, 0);
|
|
90
|
+
return (editor.view.someProp("handlePaste", (f) => f(editor.view, new Event("paste"), slice)) ?? false);
|
|
91
|
+
}
|
|
92
|
+
function pasteText(text) {
|
|
93
|
+
return paste(Fragment.from(editor.state.schema.text(text)));
|
|
94
|
+
}
|
|
95
|
+
function embeddedSrc() {
|
|
96
|
+
let src = null;
|
|
97
|
+
editor.state.doc.descendants((node) => {
|
|
98
|
+
if (node.type.name === "videoEmbed")
|
|
99
|
+
src = node.attrs.src;
|
|
100
|
+
return src === null;
|
|
101
|
+
});
|
|
102
|
+
return src;
|
|
103
|
+
}
|
|
104
|
+
it("빈 문단에 유튜브 주소를 붙여넣으면 임베드가 된다", () => {
|
|
105
|
+
createEditor();
|
|
106
|
+
expect(pasteText("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
|
|
107
|
+
expect(embeddedSrc()).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
|
108
|
+
});
|
|
109
|
+
it("유튜브가 아닌 주소는 건드리지 않는다", () => {
|
|
110
|
+
createEditor();
|
|
111
|
+
expect(pasteText("https://example.com/page")).toBe(false);
|
|
112
|
+
expect(pasteText("https://vimeo.com/123456789")).toBe(false);
|
|
113
|
+
expect(embeddedSrc()).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
it("내용이 있는 문단에서는 끼어들지 않는다", () => {
|
|
116
|
+
createEditor("<p>메모 </p>");
|
|
117
|
+
// 초기 커서는 문단 안 — 내용이 있으므로 자동 임베드가 아니라 일반 붙여넣기로 가야 한다
|
|
118
|
+
expect(pasteText("https://youtu.be/dQw4w9WgXcQ")).toBe(false);
|
|
119
|
+
expect(embeddedSrc()).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
it("링크 마크의 href 에서도 유튜브를 찾는다", () => {
|
|
122
|
+
createEditor();
|
|
123
|
+
const { schema } = editor.state;
|
|
124
|
+
const linked = schema.text("영상 보기", [
|
|
125
|
+
schema.marks.link.create({ href: "https://youtu.be/dQw4w9WgXcQ" })
|
|
126
|
+
]);
|
|
127
|
+
expect(paste(Fragment.from(linked))).toBe(true);
|
|
128
|
+
expect(embeddedSrc()).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
|
129
|
+
});
|
|
130
|
+
it("여러 블록이 담긴 붙여넣기는 대상이 아니다", () => {
|
|
131
|
+
createEditor();
|
|
132
|
+
const { schema } = editor.state;
|
|
133
|
+
const frag = Fragment.fromArray([
|
|
134
|
+
schema.nodes.paragraph.create(null, schema.text("설명")),
|
|
135
|
+
schema.nodes.paragraph.create(null, schema.text("https://youtu.be/dQw4w9WgXcQ"))
|
|
136
|
+
]);
|
|
137
|
+
expect(paste(frag)).toBe(false);
|
|
138
|
+
expect(embeddedSrc()).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
/*
|
|
142
|
+
* 살균기의 URL 검사는 `href`·`src` 만 보므로, `data-video-src` 의 스킴은 여기(확장)가
|
|
143
|
+
* 마지막 관문이다. 새로 만드는 경로는 전부 임베드 변환기를 거쳐 안전하지만 저장본은
|
|
144
|
+
* 출처를 믿을 수 없다.
|
|
145
|
+
*/
|
|
146
|
+
describe("data-video-src 스킴 관문", () => {
|
|
147
|
+
let editor;
|
|
148
|
+
function createEditor(content) {
|
|
149
|
+
editor = new Editor({
|
|
150
|
+
element: document.createElement("div"),
|
|
151
|
+
extensions: [StarterKit, VideoEmbed],
|
|
152
|
+
content
|
|
153
|
+
});
|
|
154
|
+
return editor;
|
|
155
|
+
}
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
editor?.destroy();
|
|
158
|
+
});
|
|
159
|
+
function embeddedCount() {
|
|
160
|
+
let n = 0;
|
|
161
|
+
editor.state.doc.descendants((node) => {
|
|
162
|
+
if (node.type.name === "videoEmbed")
|
|
163
|
+
n += 1;
|
|
164
|
+
return true;
|
|
165
|
+
});
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
it("javascript: 스킴 저장본은 영상 노드로 받지 않는다", () => {
|
|
169
|
+
createEditor('<div data-video-src="javascript:alert(1)"></div>');
|
|
170
|
+
expect(embeddedCount()).toBe(0);
|
|
171
|
+
expect(editor.view.dom.querySelector("iframe")).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
it("https 저장본은 그대로 통과한다", () => {
|
|
174
|
+
createEditor('<div data-video-src="https://www.youtube.com/embed/dQw4w9WgXcQ"></div>');
|
|
175
|
+
expect(embeddedCount()).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
it("parseHTML 을 안 거친 노드(JSON 삽입)도 iframe 을 만들지 않는다", () => {
|
|
178
|
+
createEditor("<p></p>");
|
|
179
|
+
editor.commands.insertContent({
|
|
180
|
+
type: "videoEmbed",
|
|
181
|
+
attrs: { src: "javascript:alert(1)" }
|
|
182
|
+
});
|
|
183
|
+
// 노드는 서 있어도 노드뷰의 이중 관문이 iframe 을 막는다
|
|
184
|
+
expect(editor.view.dom.querySelector("iframe")).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAE3C,eAAO,MAAM,EAAE,EAAE,cA+MhB,CAAC"}
|