@teriusu/rich-editor 0.9.6 → 0.9.8
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 +102 -49
- package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -1
- package/dist/components/FixedToolbar.svelte +136 -214
- package/dist/components/FixedToolbar.svelte.d.ts +9 -1
- package/dist/components/FixedToolbar.svelte.d.ts.map +1 -1
- package/dist/components/InputModal.svelte +1 -1
- package/dist/components/MathModal.svelte +111 -1
- package/dist/components/MathModal.svelte.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.svelte +206 -0
- package/dist/components/MediaPickerModal.svelte.d.ts +18 -0
- package/dist/components/MediaPickerModal.svelte.d.ts.map +1 -0
- package/dist/components/SlashCommandMenu.svelte +114 -13
- package/dist/components/SlashCommandMenu.svelte.d.ts +10 -1
- package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -1
- package/dist/components/TableBubbleMenu.svelte +71 -48
- package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -1
- package/dist/components/TipTapEditor.svelte +163 -24
- package/dist/components/TipTapEditor.svelte.d.ts +2 -0
- package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
- package/dist/extensions/NotionInputRules.d.ts +59 -0
- package/dist/extensions/NotionInputRules.d.ts.map +1 -0
- package/dist/extensions/NotionInputRules.js +141 -0
- package/dist/extensions/NotionInputRules.test.d.ts +2 -0
- package/dist/extensions/NotionInputRules.test.d.ts.map +1 -0
- package/dist/extensions/NotionInputRules.test.js +117 -0
- package/dist/extensions/VideoEmbed.d.ts +36 -0
- package/dist/extensions/VideoEmbed.d.ts.map +1 -0
- package/dist/extensions/VideoEmbed.js +246 -0
- package/dist/extensions/VideoEmbed.test.d.ts +2 -0
- package/dist/extensions/VideoEmbed.test.d.ts.map +1 -0
- package/dist/extensions/VideoEmbed.test.js +57 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/styles/editor.css +1 -1
- package/dist/types.d.ts +14 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +20 -6
- package/dist/utils/lowlight.d.ts +17 -0
- package/dist/utils/lowlight.d.ts.map +1 -0
- package/dist/utils/lowlight.js +106 -0
- package/dist/utils/lowlight.test.d.ts +2 -0
- package/dist/utils/lowlight.test.d.ts.map +1 -0
- package/dist/utils/lowlight.test.js +56 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +14 -0
- package/dist/utils/splitCell.d.ts +20 -0
- package/dist/utils/splitCell.d.ts.map +1 -0
- package/dist/utils/splitCell.js +74 -0
- package/dist/utils/table.d.ts +22 -0
- package/dist/utils/table.d.ts.map +1 -0
- package/dist/utils/table.js +68 -0
- package/package.json +6 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 노션과 같은 입력 규칙(마크다운식 단축 입력).
|
|
3
|
+
*
|
|
4
|
+
* 기본값(TipTap StarterKit)은 `> ` 가 **인용문**이지만 노션은 `> ` 가 **토글**이고 인용문은
|
|
5
|
+
* `" ` 다. 두 편집기를 오가며 쓰는 사용자가 계속 헛손질을 하게 되므로 노션 쪽에 맞춘다
|
|
6
|
+
* (사용자 요청).
|
|
7
|
+
*
|
|
8
|
+
* | 치는 것 | 되는 것 |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | `" ` | 인용문 |
|
|
11
|
+
* | `> ` | 토글 |
|
|
12
|
+
* | `# > ` · `## > ` · `### > ` | 토글 제목 1·2·3 |
|
|
13
|
+
*
|
|
14
|
+
* `# > ` 는 **한 규칙이 아니다.** `# ` 에서 StarterKit 이 이미 제목으로 바꾸고, 이어지는
|
|
15
|
+
* `> ` 를 아래 토글 규칙이 받는다. 그래서 토글 규칙은 "지금 블록이 제목이면 그 단계를
|
|
16
|
+
* 토글 제목으로 물려준다" 만 하면 된다 — 제목 안에서 `> ` 를 쳐도 똑같이 동작한다.
|
|
17
|
+
*/
|
|
18
|
+
import { Extension } from "@tiptap/core";
|
|
19
|
+
/**
|
|
20
|
+
* 인용문은 `" ` 로 만든다.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **스마트 따옴표(`“`)도 받아야 한다.** `Typography` 확장이 켜져 있으면 `"` 를 치는 순간
|
|
23
|
+
* 여는 따옴표 `“` 로 먼저 바꿔 버려서, 곧은 따옴표만 찾는 정규식은 **영영 매치되지 않는다.**
|
|
24
|
+
* (편집 모드에서만 `Typography` 를 다는데, 하필 입력 규칙이 필요한 곳이 바로 거기다.)
|
|
25
|
+
*/
|
|
26
|
+
export declare const BLOCKQUOTE_INPUT_REGEX: RegExp;
|
|
27
|
+
export declare const NotionBlockquote: import("@tiptap/core").Node<import("@tiptap/extension-blockquote").BlockquoteOptions, any>;
|
|
28
|
+
/**
|
|
29
|
+
* 토글 제목을 위해 `detailsSummary` 에 **단계(`level`)** 를 붙인 판본.
|
|
30
|
+
*
|
|
31
|
+
* 제목을 `<summary>` 안에 **노드로** 넣지 않는다. `detailsSummary` 의 content 는 `text*`
|
|
32
|
+
* 라 블록인 제목이 아예 들어가지 못하고, 스키마를 열면 옛 문서와 호환이 깨진다.
|
|
33
|
+
* 대신 `data-level` 속성 하나로 표시하고 크기는 CSS 가 준다(`editor.css` 의
|
|
34
|
+
* `summary[data-level]`). 저장된 HTML 도 `<summary data-level="1">` 한 겹이라
|
|
35
|
+
* 에디터 밖에서 읽어도 안전하다.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ 기본값은 `0`(= 일반 토글)이고, 그때는 **속성을 아예 내보내지 않는다.** 지금까지 저장된
|
|
38
|
+
* 토글에 `data-level="0"` 이 새로 붙어 문서가 통째로 바뀐 것처럼 보이는 일을 막는다.
|
|
39
|
+
*/
|
|
40
|
+
export declare const LeveledDetailsSummary: import("@tiptap/core").Node<import("@tiptap/extension-details").DetailsSummaryOptions, any>;
|
|
41
|
+
declare module "@tiptap/core" {
|
|
42
|
+
interface Commands<ReturnType> {
|
|
43
|
+
notionToggle: {
|
|
44
|
+
/**
|
|
45
|
+
* 토글을 만들고 제목 단계를 준다. `0` 이면 평범한 토글.
|
|
46
|
+
*
|
|
47
|
+
* 입력 규칙(`> `)과 메뉴(`토글 제목 1`)가 **같은 것을 쓴다** — 두 벌로 두면
|
|
48
|
+
* 한쪽만 고쳐져 "쳐서 만든 것과 메뉴로 만든 것이 다른" 상태가 된다.
|
|
49
|
+
*/
|
|
50
|
+
setToggleHeading: (level: 0 | 1 | 2 | 3) => ReturnType;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export declare const TOGGLE_INPUT_REGEX: RegExp;
|
|
55
|
+
/**
|
|
56
|
+
* `> ` → 토글. 제목 안에서 쳤으면 그 단계를 토글 제목으로 물려준다.
|
|
57
|
+
*/
|
|
58
|
+
export declare const NotionToggleInputRule: Extension<any, any>;
|
|
59
|
+
//# sourceMappingURL=NotionInputRules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NotionInputRules.d.ts","sourceRoot":"","sources":["../../src/extensions/NotionInputRules.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,SAAS,EAAgC,MAAM,cAAc,CAAC;AAIvE;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,QAAgB,CAAC;AAEpD,eAAO,MAAM,gBAAgB,4FAS3B,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,6FAehC,CAAC;AAEH,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,YAAY,EAAE;YACZ;;;;;eAKG;YACH,gBAAgB,EAAE,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,UAAU,CAAC;SACxD,CAAC;KACH;CACF;AAED,eAAO,MAAM,kBAAkB,QAAa,CAAC;AAE7C;;GAEG;AACH,eAAO,MAAM,qBAAqB,qBAuFhC,CAAC"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 노션과 같은 입력 규칙(마크다운식 단축 입력).
|
|
3
|
+
*
|
|
4
|
+
* 기본값(TipTap StarterKit)은 `> ` 가 **인용문**이지만 노션은 `> ` 가 **토글**이고 인용문은
|
|
5
|
+
* `" ` 다. 두 편집기를 오가며 쓰는 사용자가 계속 헛손질을 하게 되므로 노션 쪽에 맞춘다
|
|
6
|
+
* (사용자 요청).
|
|
7
|
+
*
|
|
8
|
+
* | 치는 것 | 되는 것 |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | `" ` | 인용문 |
|
|
11
|
+
* | `> ` | 토글 |
|
|
12
|
+
* | `# > ` · `## > ` · `### > ` | 토글 제목 1·2·3 |
|
|
13
|
+
*
|
|
14
|
+
* `# > ` 는 **한 규칙이 아니다.** `# ` 에서 StarterKit 이 이미 제목으로 바꾸고, 이어지는
|
|
15
|
+
* `> ` 를 아래 토글 규칙이 받는다. 그래서 토글 규칙은 "지금 블록이 제목이면 그 단계를
|
|
16
|
+
* 토글 제목으로 물려준다" 만 하면 된다 — 제목 안에서 `> ` 를 쳐도 똑같이 동작한다.
|
|
17
|
+
*/
|
|
18
|
+
import { Extension, InputRule, wrappingInputRule } from "@tiptap/core";
|
|
19
|
+
import { Blockquote } from "@tiptap/extension-blockquote";
|
|
20
|
+
import { DetailsSummary } from "@tiptap/extension-details";
|
|
21
|
+
/**
|
|
22
|
+
* 인용문은 `" ` 로 만든다.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ **스마트 따옴표(`“`)도 받아야 한다.** `Typography` 확장이 켜져 있으면 `"` 를 치는 순간
|
|
25
|
+
* 여는 따옴표 `“` 로 먼저 바꿔 버려서, 곧은 따옴표만 찾는 정규식은 **영영 매치되지 않는다.**
|
|
26
|
+
* (편집 모드에서만 `Typography` 를 다는데, 하필 입력 규칙이 필요한 곳이 바로 거기다.)
|
|
27
|
+
*/
|
|
28
|
+
export const BLOCKQUOTE_INPUT_REGEX = /^\s*["“]\s$/;
|
|
29
|
+
export const NotionBlockquote = Blockquote.extend({
|
|
30
|
+
addInputRules() {
|
|
31
|
+
return [
|
|
32
|
+
wrappingInputRule({
|
|
33
|
+
find: BLOCKQUOTE_INPUT_REGEX,
|
|
34
|
+
type: this.type,
|
|
35
|
+
}),
|
|
36
|
+
];
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* 토글 제목을 위해 `detailsSummary` 에 **단계(`level`)** 를 붙인 판본.
|
|
41
|
+
*
|
|
42
|
+
* 제목을 `<summary>` 안에 **노드로** 넣지 않는다. `detailsSummary` 의 content 는 `text*`
|
|
43
|
+
* 라 블록인 제목이 아예 들어가지 못하고, 스키마를 열면 옛 문서와 호환이 깨진다.
|
|
44
|
+
* 대신 `data-level` 속성 하나로 표시하고 크기는 CSS 가 준다(`editor.css` 의
|
|
45
|
+
* `summary[data-level]`). 저장된 HTML 도 `<summary data-level="1">` 한 겹이라
|
|
46
|
+
* 에디터 밖에서 읽어도 안전하다.
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ 기본값은 `0`(= 일반 토글)이고, 그때는 **속성을 아예 내보내지 않는다.** 지금까지 저장된
|
|
49
|
+
* 토글에 `data-level="0"` 이 새로 붙어 문서가 통째로 바뀐 것처럼 보이는 일을 막는다.
|
|
50
|
+
*/
|
|
51
|
+
export const LeveledDetailsSummary = DetailsSummary.extend({
|
|
52
|
+
addAttributes() {
|
|
53
|
+
return {
|
|
54
|
+
...this.parent?.(),
|
|
55
|
+
level: {
|
|
56
|
+
default: 0,
|
|
57
|
+
parseHTML: (element) => {
|
|
58
|
+
const raw = Number(element.getAttribute("data-level"));
|
|
59
|
+
return raw === 1 || raw === 2 || raw === 3 ? raw : 0;
|
|
60
|
+
},
|
|
61
|
+
renderHTML: (attributes) => attributes.level ? { "data-level": String(attributes.level) } : {},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
export const TOGGLE_INPUT_REGEX = /^\s*>\s$/;
|
|
67
|
+
/**
|
|
68
|
+
* `> ` → 토글. 제목 안에서 쳤으면 그 단계를 토글 제목으로 물려준다.
|
|
69
|
+
*/
|
|
70
|
+
export const NotionToggleInputRule = Extension.create({
|
|
71
|
+
name: "notionToggleInputRule",
|
|
72
|
+
addCommands() {
|
|
73
|
+
return {
|
|
74
|
+
setToggleHeading: (level) => ({ chain, state }) => {
|
|
75
|
+
const { schema, selection } = state;
|
|
76
|
+
if (!schema.nodes.details || !schema.nodes.detailsContent)
|
|
77
|
+
return false;
|
|
78
|
+
const range = selection.$from.blockRange(selection.$to);
|
|
79
|
+
if (!range)
|
|
80
|
+
return false;
|
|
81
|
+
const slice = state.doc.slice(range.start, range.end);
|
|
82
|
+
if (!schema.nodes.detailsContent.contentMatch.matchFragment(slice.content)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/*
|
|
86
|
+
* 안으로 들어갈 내용. **제목은 문단으로 눕혀서** 넣는다 — 제목인 채로 두면
|
|
87
|
+
* 접힌 안쪽에 큰 글자가 숨고, 정작 토글 머리는 빈 줄이 된다.
|
|
88
|
+
* (단계는 아래 `detailsSummary` 의 `level` 로 옮겨 간다.)
|
|
89
|
+
*/
|
|
90
|
+
const content = (slice.toJSON()?.content || []).map((node) => node?.type === "heading"
|
|
91
|
+
? { ...node, type: "paragraph", attrs: undefined }
|
|
92
|
+
: node);
|
|
93
|
+
/*
|
|
94
|
+
* ⚠️ **한 번에 만든다.** 예전엔 `setDetails()` 로 만든 뒤 `updateAttributes` 로
|
|
95
|
+
* 단계를 덧붙였는데, 그 사이 `setNodeMarkup` 이 selection 을 다시 매핑하면서
|
|
96
|
+
* **빈 제목 칸에 있던 커서가 토글 바깥으로 밀렸다** — 메뉴로 토글을 만들면
|
|
97
|
+
* 이어 친 글자가 토글 아래 새 문단에 들어갔다(실측).
|
|
98
|
+
* 노드를 처음부터 옳게 만들면 커서를 한 번만 놓으면 된다.
|
|
99
|
+
*/
|
|
100
|
+
return chain()
|
|
101
|
+
.insertContentAt({ from: range.start, to: range.end }, {
|
|
102
|
+
type: "details",
|
|
103
|
+
content: [
|
|
104
|
+
{ type: "detailsSummary", attrs: { level } },
|
|
105
|
+
{ type: "detailsContent", content },
|
|
106
|
+
],
|
|
107
|
+
})
|
|
108
|
+
/* details(1) + detailsSummary(1) = 제목 칸 안쪽. */
|
|
109
|
+
.setTextSelection(range.start + 2)
|
|
110
|
+
.run();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
addInputRules() {
|
|
115
|
+
return [
|
|
116
|
+
new InputRule({
|
|
117
|
+
find: TOGGLE_INPUT_REGEX,
|
|
118
|
+
handler: ({ state, range, chain }) => {
|
|
119
|
+
/*
|
|
120
|
+
* 토글 확장이 없는 조합(호스트가 details 를 빼고 쓰는 경우)에서는 아무 일도
|
|
121
|
+
* 하지 않는다. `null` 을 돌려주면 규칙이 적용되지 않고 친 글자가 그대로 남는다.
|
|
122
|
+
*/
|
|
123
|
+
if (!state.schema.nodes.details)
|
|
124
|
+
return null;
|
|
125
|
+
const $start = state.doc.resolve(range.from);
|
|
126
|
+
const parent = $start.parent;
|
|
127
|
+
/*
|
|
128
|
+
* ⚠️ **토글 제목 줄에서는 걸지 않는다.** `detailsSummary` 의 content 는 `text*`
|
|
129
|
+
* 라 그 안에 토글을 넣을 수 없어 `setDetails()` 가 실패하는데, 그러면 친 `> ` 만
|
|
130
|
+
* 사라지고 아무것도 안 생긴 것처럼 보인다.
|
|
131
|
+
*/
|
|
132
|
+
if (parent.type.name === "detailsSummary")
|
|
133
|
+
return null;
|
|
134
|
+
const level = (parent.type.name === "heading" ? Number(parent.attrs.level) || 0 : 0);
|
|
135
|
+
/* 친 `> ` 를 지우고, 나머지는 메뉴와 **같은 커맨드**에 맡긴다. */
|
|
136
|
+
chain().deleteRange(range).setToggleHeading(level).run();
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
];
|
|
140
|
+
},
|
|
141
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NotionInputRules.test.d.ts","sourceRoot":"","sources":["../../src/extensions/NotionInputRules.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
4
|
+
import { DetailsContent } from '@tiptap/extension-details';
|
|
5
|
+
import { FixedDetails } from './FixedDetails';
|
|
6
|
+
import { NotionBlockquote, LeveledDetailsSummary, NotionToggleInputRule, BLOCKQUOTE_INPUT_REGEX, TOGGLE_INPUT_REGEX } from './NotionInputRules';
|
|
7
|
+
/*
|
|
8
|
+
* 토글·토글 제목은 **커서가 어디 남는지**가 곧 사용성이다. 만들어졌는지만 보면
|
|
9
|
+
* "토글은 생겼는데 이어 친 글자가 엉뚱한 데 들어가는" 상태를 놓친다 — 실제로 그랬다.
|
|
10
|
+
* 그래서 노드 구조와 selection 을 **함께** 단언한다.
|
|
11
|
+
*/
|
|
12
|
+
describe('노션식 입력 규칙 · 토글 제목', () => {
|
|
13
|
+
let editor;
|
|
14
|
+
function createEditor(content = '<p></p>') {
|
|
15
|
+
editor = new Editor({
|
|
16
|
+
element: document.createElement('div'),
|
|
17
|
+
extensions: [
|
|
18
|
+
StarterKit.configure({ blockquote: false }),
|
|
19
|
+
NotionBlockquote,
|
|
20
|
+
FixedDetails,
|
|
21
|
+
DetailsContent,
|
|
22
|
+
LeveledDetailsSummary,
|
|
23
|
+
NotionToggleInputRule
|
|
24
|
+
],
|
|
25
|
+
content
|
|
26
|
+
});
|
|
27
|
+
return editor;
|
|
28
|
+
}
|
|
29
|
+
const json = () => editor.getJSON();
|
|
30
|
+
const firstBlock = () => json().content?.[0];
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
editor?.destroy();
|
|
33
|
+
});
|
|
34
|
+
describe('setToggleHeading', () => {
|
|
35
|
+
it('빈 문단에서 토글을 만들고 커서를 제목 칸에 둔다', () => {
|
|
36
|
+
createEditor();
|
|
37
|
+
editor.commands.setToggleHeading(0);
|
|
38
|
+
const details = firstBlock();
|
|
39
|
+
expect(details?.type).toBe('details');
|
|
40
|
+
expect(details?.content?.[0].type).toBe('detailsSummary');
|
|
41
|
+
/*
|
|
42
|
+
* ⚠️ 핵심 단언. 커서가 제목 칸(`detailsSummary`) 안에 있어야 이어서 친 글자가
|
|
43
|
+
* 토글 제목이 된다. 예전 구현(`setDetails` → `updateAttributes`)에서는 여기서
|
|
44
|
+
* 커서가 토글 **바깥**으로 밀려 글자가 아래 문단에 들어갔다.
|
|
45
|
+
*/
|
|
46
|
+
const { $from } = editor.state.selection;
|
|
47
|
+
expect($from.parent.type.name).toBe('detailsSummary');
|
|
48
|
+
});
|
|
49
|
+
it('이어서 친 글자가 제목 칸에 들어간다', () => {
|
|
50
|
+
createEditor();
|
|
51
|
+
editor.commands.setToggleHeading(1);
|
|
52
|
+
editor.commands.insertContent('접히는 제목');
|
|
53
|
+
const summary = firstBlock()?.content?.[0];
|
|
54
|
+
expect(summary?.type).toBe('detailsSummary');
|
|
55
|
+
expect(summary?.content?.[0].text).toBe('접히는 제목');
|
|
56
|
+
});
|
|
57
|
+
it('단계를 detailsSummary 의 level 로 남긴다', () => {
|
|
58
|
+
createEditor();
|
|
59
|
+
editor.commands.setToggleHeading(2);
|
|
60
|
+
expect(firstBlock()?.content?.[0].attrs?.level).toBe(2);
|
|
61
|
+
});
|
|
62
|
+
it('level 0 이면 data-level 을 아예 내보내지 않는다(옛 문서와 같은 모양)', () => {
|
|
63
|
+
createEditor();
|
|
64
|
+
editor.commands.setToggleHeading(0);
|
|
65
|
+
expect(editor.getHTML()).not.toContain('data-level');
|
|
66
|
+
});
|
|
67
|
+
it('level 1 은 data-level="1" 로 저장된다', () => {
|
|
68
|
+
createEditor();
|
|
69
|
+
editor.commands.setToggleHeading(1);
|
|
70
|
+
expect(editor.getHTML()).toContain('data-level="1"');
|
|
71
|
+
});
|
|
72
|
+
/*
|
|
73
|
+
* ⚠️ 제목 위에서 부르면 제목이 **본문으로 눕혀져** 안에 들어가야 한다.
|
|
74
|
+
* 제목인 채로 들어가면 큰 글자가 접힌 안쪽에 숨고 토글 머리는 빈 줄이 된다.
|
|
75
|
+
*/
|
|
76
|
+
it('제목 위에서 부르면 제목을 문단으로 눕혀 안에 넣는다', () => {
|
|
77
|
+
createEditor('<h1>원래 제목</h1>');
|
|
78
|
+
editor.commands.selectAll();
|
|
79
|
+
editor.commands.setToggleHeading(1);
|
|
80
|
+
const details = firstBlock();
|
|
81
|
+
const inner = details?.content?.[1];
|
|
82
|
+
expect(inner?.type).toBe('detailsContent');
|
|
83
|
+
expect(inner?.content?.[0].type).toBe('paragraph');
|
|
84
|
+
expect(inner?.content?.[0].content?.[0].text).toBe('원래 제목');
|
|
85
|
+
});
|
|
86
|
+
it('옛 문서의 토글(data-level 없음)은 level 0 으로 읽힌다', () => {
|
|
87
|
+
createEditor('<details><summary>옛 토글</summary><div>본문</div></details>');
|
|
88
|
+
expect(firstBlock()?.content?.[0].attrs?.level).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('인용문 입력 규칙', () => {
|
|
92
|
+
it('StarterKit 의 `> ` 대신 `" ` 가 인용문을 만든다', () => {
|
|
93
|
+
createEditor();
|
|
94
|
+
// 입력 규칙은 실제 타이핑에만 걸리므로 명령으로 대신 확인한다.
|
|
95
|
+
editor.commands.toggleBlockquote();
|
|
96
|
+
expect(firstBlock()?.type).toBe('blockquote');
|
|
97
|
+
});
|
|
98
|
+
/*
|
|
99
|
+
* ⚠️ **두 규칙이 같은 키를 노리면 안 된다.** `> ` 가 인용문에도 남아 있으면 어느
|
|
100
|
+
* 쪽이 이길지 확장 등록 순서에 달리게 되어, 같은 키를 쳤는데 결과가 갈린다.
|
|
101
|
+
* 그래서 겹치지 않는지를 정규식 수준에서 못박는다.
|
|
102
|
+
*/
|
|
103
|
+
it('`>` 는 토글만, `"` 는 인용문만 받는다 (서로 겹치지 않는다)', () => {
|
|
104
|
+
expect(TOGGLE_INPUT_REGEX.test('> ')).toBe(true);
|
|
105
|
+
expect(BLOCKQUOTE_INPUT_REGEX.test('> ')).toBe(false);
|
|
106
|
+
expect(BLOCKQUOTE_INPUT_REGEX.test('" ')).toBe(true);
|
|
107
|
+
expect(TOGGLE_INPUT_REGEX.test('" ')).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
/*
|
|
110
|
+
* `Typography` 가 `"` 를 여는 따옴표로 먼저 바꾸므로, 곧은 따옴표만 찾으면
|
|
111
|
+
* 규칙이 영영 안 걸린다.
|
|
112
|
+
*/
|
|
113
|
+
it('스마트 따옴표(“)도 인용문으로 받는다', () => {
|
|
114
|
+
expect(BLOCKQUOTE_INPUT_REGEX.test('“ ')).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 범용 영상 임베드 — 유튜브·Vimeo, 그리고 그대로 넣을 수 있는 임베드 주소.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **`MbusVideo` 와 따로 둔다.** 틀(16:9 iframe)은 똑같지만, mbus 노드는 앱 쪽
|
|
5
|
+
* `MidibusInner.svelte` 가 `play.mbus.tv` 전용 파라미터(`label`·`start`·`volume`)를 붙여
|
|
6
|
+
* 처리한다. 유튜브 주소를 `data-mbus-src` 로 저장하면 그 경로로 흘러 들어가 엉뚱하게
|
|
7
|
+
* 다뤄진다. 저장 형식이 곧 계약이라, 담기는 것이 달라지면 이름도 달라야 한다.
|
|
8
|
+
*/
|
|
9
|
+
import { Node } from "@tiptap/core";
|
|
10
|
+
export interface VideoEmbedOptions {
|
|
11
|
+
HTMLAttributes: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
declare module "@tiptap/core" {
|
|
14
|
+
interface Commands<ReturnType> {
|
|
15
|
+
videoEmbed: {
|
|
16
|
+
setVideoEmbed: (attrs: {
|
|
17
|
+
src: string;
|
|
18
|
+
width?: string;
|
|
19
|
+
}) => ReturnType;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 붙여넣은 주소를 **iframe 에 넣을 수 있는 주소**로 바꾼다.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ 이 변환이 이 확장의 핵심이다. 사람들이 붙여넣는 `youtube.com/watch?v=…` 를 그대로
|
|
27
|
+
* iframe 에 넣으면 유튜브가 `X-Frame-Options` 로 **거부**한다(화면엔 빈 칸만 남는다).
|
|
28
|
+
* `embed/<id>` 로 바꿔야 비로소 실린다. mbus 주소는 그 자체가 플레이어 페이지라 이런
|
|
29
|
+
* 손질이 필요 없었고, 그래서 `MbusVideo` 에는 이런 코드가 아예 없다.
|
|
30
|
+
*
|
|
31
|
+
* 아는 서비스가 아니면 **손대지 않고 그대로 돌려준다** — 이미 임베드 주소를 들고 온
|
|
32
|
+
* 경우(사내 플레이어 등)를 막지 않기 위해서다.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toEmbedUrl(raw: string): string;
|
|
35
|
+
export declare const VideoEmbed: Node<VideoEmbedOptions, any>;
|
|
36
|
+
//# sourceMappingURL=VideoEmbed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoEmbed.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoEmbed.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAGrD,MAAM,WAAW,iBAAiB;IACjC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,OAAO,QAAQ,cAAc,CAAC;IAC7B,UAAU,QAAQ,CAAC,UAAU;QAC5B,UAAU,EAAE;YACX,aAAa,EAAE,CAAC,KAAK,EAAE;gBAAE,GAAG,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAA;aAAE,KAAK,UAAU,CAAC;SACtE,CAAC;KACF;CACD;AAWD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAqD9C;AAmBD,eAAO,MAAM,UAAU,8BA0JrB,CAAC"}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 범용 영상 임베드 — 유튜브·Vimeo, 그리고 그대로 넣을 수 있는 임베드 주소.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **`MbusVideo` 와 따로 둔다.** 틀(16:9 iframe)은 똑같지만, mbus 노드는 앱 쪽
|
|
5
|
+
* `MidibusInner.svelte` 가 `play.mbus.tv` 전용 파라미터(`label`·`start`·`volume`)를 붙여
|
|
6
|
+
* 처리한다. 유튜브 주소를 `data-mbus-src` 로 저장하면 그 경로로 흘러 들어가 엉뚱하게
|
|
7
|
+
* 다뤄진다. 저장 형식이 곧 계약이라, 담기는 것이 달라지면 이름도 달라야 한다.
|
|
8
|
+
*/
|
|
9
|
+
import { Node, mergeAttributes } from "@tiptap/core";
|
|
10
|
+
import { attachResize } from "../utils/resize";
|
|
11
|
+
/** `1m30s` · `90` 처럼 적히는 유튜브 타임스탬프를 초로 바꾼다. */
|
|
12
|
+
function parseTimestamp(raw) {
|
|
13
|
+
if (!raw)
|
|
14
|
+
return null;
|
|
15
|
+
if (/^\d+$/.test(raw))
|
|
16
|
+
return Number(raw);
|
|
17
|
+
const m = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
|
18
|
+
if (!m || (!m[1] && !m[2] && !m[3]))
|
|
19
|
+
return null;
|
|
20
|
+
return Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 붙여넣은 주소를 **iframe 에 넣을 수 있는 주소**로 바꾼다.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 이 변환이 이 확장의 핵심이다. 사람들이 붙여넣는 `youtube.com/watch?v=…` 를 그대로
|
|
26
|
+
* iframe 에 넣으면 유튜브가 `X-Frame-Options` 로 **거부**한다(화면엔 빈 칸만 남는다).
|
|
27
|
+
* `embed/<id>` 로 바꿔야 비로소 실린다. mbus 주소는 그 자체가 플레이어 페이지라 이런
|
|
28
|
+
* 손질이 필요 없었고, 그래서 `MbusVideo` 에는 이런 코드가 아예 없다.
|
|
29
|
+
*
|
|
30
|
+
* 아는 서비스가 아니면 **손대지 않고 그대로 돌려준다** — 이미 임베드 주소를 들고 온
|
|
31
|
+
* 경우(사내 플레이어 등)를 막지 않기 위해서다.
|
|
32
|
+
*/
|
|
33
|
+
export function toEmbedUrl(raw) {
|
|
34
|
+
const trimmed = raw.trim();
|
|
35
|
+
if (!trimmed)
|
|
36
|
+
return trimmed;
|
|
37
|
+
let u;
|
|
38
|
+
try {
|
|
39
|
+
u = new URL(trimmed);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return trimmed;
|
|
43
|
+
}
|
|
44
|
+
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
|
+
// ── Vimeo ──
|
|
78
|
+
if (host === "vimeo.com" || host === "player.vimeo.com") {
|
|
79
|
+
if (host === "player.vimeo.com")
|
|
80
|
+
return trimmed; // 이미 플레이어 주소
|
|
81
|
+
// /123456 · /channels/xxx/123456 · /groups/xxx/videos/123456
|
|
82
|
+
const id = u.pathname.split("/").filter(Boolean).filter((p) => /^\d+$/.test(p)).pop();
|
|
83
|
+
if (!id)
|
|
84
|
+
return trimmed;
|
|
85
|
+
return `https://player.vimeo.com/video/${id}`;
|
|
86
|
+
}
|
|
87
|
+
return trimmed;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 이 브라우저에서 **COEP 없는 남의 iframe** 을 실을 수 있는지.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ 정올은 전 브라우저에 `COEP: require-corp`(교차 출처 격리)를 건다. 격리된 문서에
|
|
93
|
+
* 자기 COEP 를 안 보내는 제3자 iframe(유튜브·Vimeo·mbus 전부 해당)을 넣는 방법은
|
|
94
|
+
* `credentialless` 속성뿐인데 **크로미움 전용**이다(파이어폭스 미구현·사파리 거부).
|
|
95
|
+
* 그래서 정올 파폭·사파리에서는 mbus 영상이 지금 **빈 회색 박스**로 남아 있다.
|
|
96
|
+
*
|
|
97
|
+
* 격리되지 않은 문서(코드패스의 비-크로미움 등)에서는 평범한 크로스오리진 iframe 이라
|
|
98
|
+
* 아무 문제가 없다 — 그래서 격리 여부와 지원 여부를 **함께** 본다.
|
|
99
|
+
*/
|
|
100
|
+
function canEmbedCrossOrigin() {
|
|
101
|
+
if (typeof window === "undefined")
|
|
102
|
+
return true;
|
|
103
|
+
if (!window.crossOriginIsolated)
|
|
104
|
+
return true;
|
|
105
|
+
return "credentialless" in HTMLIFrameElement.prototype;
|
|
106
|
+
}
|
|
107
|
+
export const VideoEmbed = Node.create({
|
|
108
|
+
name: "videoEmbed",
|
|
109
|
+
group: "block",
|
|
110
|
+
atom: true,
|
|
111
|
+
draggable: true,
|
|
112
|
+
addOptions() {
|
|
113
|
+
return { HTMLAttributes: {} };
|
|
114
|
+
},
|
|
115
|
+
addAttributes() {
|
|
116
|
+
return {
|
|
117
|
+
src: { default: null },
|
|
118
|
+
width: { default: null }
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
parseHTML() {
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
tag: "div[data-video-src]",
|
|
125
|
+
getAttrs: (dom) => {
|
|
126
|
+
const el = dom;
|
|
127
|
+
return {
|
|
128
|
+
src: el.getAttribute("data-video-src"),
|
|
129
|
+
width: el.getAttribute("data-video-width") || el.style?.width || null
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
},
|
|
135
|
+
renderHTML({ HTMLAttributes }) {
|
|
136
|
+
const attrs = {
|
|
137
|
+
"data-video-src": HTMLAttributes.src
|
|
138
|
+
};
|
|
139
|
+
if (HTMLAttributes.width) {
|
|
140
|
+
attrs["data-video-width"] = HTMLAttributes.width;
|
|
141
|
+
attrs["style"] = `width: ${HTMLAttributes.width}`;
|
|
142
|
+
}
|
|
143
|
+
return ["div", mergeAttributes(this.options.HTMLAttributes, attrs)];
|
|
144
|
+
},
|
|
145
|
+
addNodeView() {
|
|
146
|
+
return ({ node, editor, getPos }) => {
|
|
147
|
+
// 리사이즈가 attrs 를 되쓰므로 **항상 최신 노드**여야 한다(mbus 의 stale 버그와 같은 자리).
|
|
148
|
+
let currentNode = node;
|
|
149
|
+
let detachResize = null;
|
|
150
|
+
const dom = document.createElement("div");
|
|
151
|
+
dom.setAttribute("data-type", "videoEmbed");
|
|
152
|
+
dom.setAttribute("data-node-view-wrapper", "");
|
|
153
|
+
dom.style.cssText = "margin:8px 0;position:relative;box-sizing:border-box;max-width:100%;";
|
|
154
|
+
if (node.attrs.width)
|
|
155
|
+
dom.style.width = node.attrs.width;
|
|
156
|
+
const aspect = document.createElement("div");
|
|
157
|
+
aspect.style.cssText =
|
|
158
|
+
"position:relative;width:100%;padding-top:56.25%;background:#0b1020;border-radius:8px;overflow:hidden;";
|
|
159
|
+
dom.appendChild(aspect);
|
|
160
|
+
const src = node.attrs.src;
|
|
161
|
+
if (src && canEmbedCrossOrigin()) {
|
|
162
|
+
const iframe = document.createElement("iframe");
|
|
163
|
+
iframe.src = src;
|
|
164
|
+
iframe.allow = "autoplay; fullscreen; encrypted-media; picture-in-picture";
|
|
165
|
+
iframe.setAttribute("allowfullscreen", "");
|
|
166
|
+
iframe.setAttribute("loading", "lazy");
|
|
167
|
+
/*
|
|
168
|
+
* 격리된 문서에서 COEP 없는 영상을 싣는 유일한 통로. 크로미움이 아니면 이
|
|
169
|
+
* 속성 자체가 무시되므로 조건 없이 붙여도 해가 없다.
|
|
170
|
+
*/
|
|
171
|
+
iframe.setAttribute("credentialless", "");
|
|
172
|
+
iframe.style.cssText =
|
|
173
|
+
"position:absolute;inset:0;width:100%;height:100%;border:0;display:block;";
|
|
174
|
+
aspect.appendChild(iframe);
|
|
175
|
+
}
|
|
176
|
+
else if (src) {
|
|
177
|
+
/*
|
|
178
|
+
* 실을 수 없는 브라우저 — **빈 박스로 두지 않는다.** 그러면 사용자는 영상이
|
|
179
|
+
* 깨진 줄 알지 이유를 모른다. 무엇이 있었는지와 나가는 길을 보여 준다.
|
|
180
|
+
*/
|
|
181
|
+
const poster = document.createElement("div");
|
|
182
|
+
poster.style.cssText =
|
|
183
|
+
"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
|
+
const label = document.createElement("span");
|
|
185
|
+
label.textContent = "이 브라우저에서는 영상을 여기 띄울 수 없습니다";
|
|
186
|
+
label.style.cssText = "font-size:13px;line-height:1.5;";
|
|
187
|
+
poster.appendChild(label);
|
|
188
|
+
const open = document.createElement("a");
|
|
189
|
+
open.href = src;
|
|
190
|
+
open.target = "_blank";
|
|
191
|
+
open.rel = "noopener noreferrer";
|
|
192
|
+
open.textContent = "새 탭에서 보기";
|
|
193
|
+
open.style.cssText =
|
|
194
|
+
"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
|
+
poster.appendChild(open);
|
|
196
|
+
aspect.appendChild(poster);
|
|
197
|
+
}
|
|
198
|
+
if (editor.isEditable) {
|
|
199
|
+
const del = document.createElement("button");
|
|
200
|
+
del.type = "button";
|
|
201
|
+
del.textContent = "×";
|
|
202
|
+
del.style.cssText =
|
|
203
|
+
"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;";
|
|
204
|
+
del.addEventListener("click", () => {
|
|
205
|
+
const pos = typeof getPos === "function" ? getPos() : null;
|
|
206
|
+
if (pos != null) {
|
|
207
|
+
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
dom.appendChild(del);
|
|
211
|
+
detachResize = attachResize({
|
|
212
|
+
dom,
|
|
213
|
+
editor,
|
|
214
|
+
getPos: () => (typeof getPos === "function" ? getPos() : undefined),
|
|
215
|
+
getNode: () => currentNode,
|
|
216
|
+
axis: "x",
|
|
217
|
+
label: "영상 너비 조절"
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
dom,
|
|
222
|
+
update: (updated) => {
|
|
223
|
+
if (updated.type !== currentNode.type)
|
|
224
|
+
return false;
|
|
225
|
+
const w = updated.attrs.width;
|
|
226
|
+
dom.style.width = w || "";
|
|
227
|
+
currentNode = updated;
|
|
228
|
+
return true;
|
|
229
|
+
},
|
|
230
|
+
destroy: () => {
|
|
231
|
+
detachResize?.();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
addCommands() {
|
|
237
|
+
return {
|
|
238
|
+
setVideoEmbed: (attrs) => ({ chain }) => {
|
|
239
|
+
// 넣는 길이 여럿이라(툴바·슬래시·붙여넣기) **여기서 한 번에** 정규화한다.
|
|
240
|
+
return chain()
|
|
241
|
+
.insertContent({ type: this.name, attrs: { ...attrs, src: toEmbedUrl(attrs.src) } })
|
|
242
|
+
.run();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoEmbed.test.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoEmbed.test.ts"],"names":[],"mappings":""}
|