mdsquare 0.0.0-stage → 0.1.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -2
  3. package/dist/callout.d.ts +17 -0
  4. package/dist/callout.js +541 -0
  5. package/dist/calloutHeader.d.ts +47 -0
  6. package/dist/calloutHeader.js +75 -0
  7. package/dist/codeBlock.d.ts +78 -0
  8. package/dist/codeBlock.js +686 -0
  9. package/dist/codeTheme.d.ts +47 -0
  10. package/dist/codeTheme.js +394 -0
  11. package/dist/delimiter.d.ts +20 -0
  12. package/dist/delimiter.js +73 -0
  13. package/dist/editor.d.ts +114 -0
  14. package/dist/editor.js +199 -0
  15. package/dist/emphasis.d.ts +6 -0
  16. package/dist/emphasis.js +101 -0
  17. package/dist/escape.d.ts +4 -0
  18. package/dist/escape.js +39 -0
  19. package/dist/extensions.d.ts +66 -0
  20. package/dist/extensions.js +79 -0
  21. package/dist/footnote.d.ts +24 -0
  22. package/dist/footnote.js +456 -0
  23. package/dist/footnoteSyntax.d.ts +23 -0
  24. package/dist/footnoteSyntax.js +107 -0
  25. package/dist/hashtag.d.ts +4 -0
  26. package/dist/hashtag.js +64 -0
  27. package/dist/hashtagSyntax.d.ts +36 -0
  28. package/dist/hashtagSyntax.js +99 -0
  29. package/dist/heading.d.ts +7 -0
  30. package/dist/heading.js +159 -0
  31. package/dist/highlight.d.ts +5 -0
  32. package/dist/highlight.js +6 -0
  33. package/dist/horizontalRule.d.ts +21 -0
  34. package/dist/horizontalRule.js +142 -0
  35. package/dist/image.d.ts +58 -0
  36. package/dist/image.js +344 -0
  37. package/dist/index.d.ts +16 -0
  38. package/dist/index.js +16 -0
  39. package/dist/inlineCode.d.ts +4 -0
  40. package/dist/inlineCode.js +68 -0
  41. package/dist/link.d.ts +47 -0
  42. package/dist/link.js +217 -0
  43. package/dist/list.d.ts +14 -0
  44. package/dist/list.js +136 -0
  45. package/dist/listEnter.d.ts +20 -0
  46. package/dist/listEnter.js +72 -0
  47. package/dist/listIndent.d.ts +23 -0
  48. package/dist/listIndent.js +335 -0
  49. package/dist/markVision.d.ts +17 -0
  50. package/dist/markVision.js +56 -0
  51. package/dist/math.d.ts +45 -0
  52. package/dist/math.js +233 -0
  53. package/dist/mathSyntax.d.ts +24 -0
  54. package/dist/mathSyntax.js +179 -0
  55. package/dist/mention.d.ts +4 -0
  56. package/dist/mention.js +58 -0
  57. package/dist/mentionSyntax.d.ts +14 -0
  58. package/dist/mentionSyntax.js +71 -0
  59. package/dist/quote.d.ts +9 -0
  60. package/dist/quote.js +122 -0
  61. package/dist/setup.d.ts +13 -0
  62. package/dist/setup.js +40 -0
  63. package/dist/style.css +6 -0
  64. package/dist/style.css.d.ts +2 -0
  65. package/dist/table.d.ts +38 -0
  66. package/dist/table.js +960 -0
  67. package/dist/tableEdit.d.ts +30 -0
  68. package/dist/tableEdit.js +305 -0
  69. package/dist/tableMenu.d.ts +65 -0
  70. package/dist/tableMenu.js +1125 -0
  71. package/dist/tableModel.d.ts +168 -0
  72. package/dist/tableModel.js +433 -0
  73. package/dist/tableState.d.ts +36 -0
  74. package/dist/tableState.js +96 -0
  75. package/dist/task.d.ts +13 -0
  76. package/dist/task.js +179 -0
  77. package/dist/textMate.d.ts +49 -0
  78. package/dist/textMate.js +284 -0
  79. package/dist/tikz.d.ts +56 -0
  80. package/dist/tikz.js +343 -0
  81. package/dist/underline.d.ts +5 -0
  82. package/dist/underline.js +6 -0
  83. package/dist/utils/blockMargin.d.ts +6 -0
  84. package/dist/utils/blockMargin.js +6 -0
  85. package/dist/utils/codePoint.d.ts +13 -0
  86. package/dist/utils/codePoint.js +43 -0
  87. package/dist/utils/container.d.ts +10 -0
  88. package/dist/utils/container.js +10 -0
  89. package/dist/utils/reveal.d.ts +99 -0
  90. package/dist/utils/reveal.js +161 -0
  91. package/dist/utils/selection.d.ts +33 -0
  92. package/dist/utils/selection.js +45 -0
  93. package/dist/utils/syntaxTree.d.ts +18 -0
  94. package/dist/utils/syntaxTree.js +23 -0
  95. package/package.json +79 -4
@@ -0,0 +1,456 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import { EditorSelection, RangeSet, StateField } from "@codemirror/state";
3
+ import { Decoration, EditorView, GutterMarker, gutterLineClass, hoverTooltip } from "@codemirror/view";
4
+ import { readImage } from "./image.js";
5
+ import { isSafeUrl, readInlineLink } from "./link.js";
6
+ import { createRevealPlugin } from "./utils/reveal.js";
7
+ /** 라벨 비교는 대소문자를 구분하지 않습니다. */
8
+ const normalize = (label) => label.toLowerCase();
9
+ const TOOLTIP_MAX = 200;
10
+ const cache = new WeakMap();
11
+ /**
12
+ * 문서의 각주 정의를 라벨별로 찾습니다. (같은 라벨이 여러 번 정의되면 첫 번째)
13
+ * 정의는 최상위 블록에만 오므로 최상위 블록만 확인하고, 결과는 구문 트리별로 캐시합니다.
14
+ * (커서만 움직이는 동안에는 다시 계산하지 않습니다)
15
+ */
16
+ export function footnoteDefinitions(state) {
17
+ const tree = syntaxTree(state);
18
+ let defs = cache.get(tree);
19
+ if (defs)
20
+ return defs;
21
+ defs = new Map();
22
+ const cursor = tree.cursor();
23
+ if (cursor.firstChild()) {
24
+ do {
25
+ if (!cursor.type.is("FootnoteDefinition"))
26
+ continue;
27
+ const node = cursor.node;
28
+ const labelNode = node.getChild("FootnoteLabel");
29
+ const marks = node.getChildren("FootnoteMark");
30
+ if (!labelNode || marks.length < 2)
31
+ continue;
32
+ const label = normalize(state.doc.sliceString(labelNode.from, labelNode.to));
33
+ if (defs.has(label))
34
+ continue;
35
+ const raw = state.doc.sliceString(marks[1].to, node.to);
36
+ const text = raw.trim().replace(/\s+/g, " ");
37
+ defs.set(label, {
38
+ label,
39
+ from: node.from,
40
+ to: node.to,
41
+ contentFrom: marks[1].to + (raw.length - raw.trimStart().length),
42
+ text: text.length > TOOLTIP_MAX ? `${text.slice(0, TOOLTIP_MAX)}…` : text,
43
+ });
44
+ } while (cursor.nextSibling());
45
+ }
46
+ cache.set(tree, defs);
47
+ return defs;
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // 데코레이션
51
+ // ---------------------------------------------------------------------------
52
+ const hidden = Decoration.replace({});
53
+ const formatting = Decoration.mark({ class: "cm-formatting cm-formatting-footnote" });
54
+ const revealedRefLabel = Decoration.mark({ class: "cm-footnote-label" });
55
+ const defLine = Decoration.line({ class: "cm-footnote-def-line" });
56
+ /** 연속된 정의 묶음의 첫 줄: 본문과 구분하는 구분선을 위에 그립니다. */
57
+ const defLineGroupStart = Decoration.line({ class: "cm-footnote-def-line cm-footnote-defs-start" });
58
+ const defLabel = Decoration.mark({ class: "cm-footnote-def-label" });
59
+ /** 참조 라벨 (툴팁은 hoverTooltip이 담당하므로 title 속성을 쓰지 않습니다) */
60
+ const refLabel = Decoration.mark({ class: "cm-footnote-ref" });
61
+ const refLabelMissing = Decoration.mark({ class: "cm-footnote-ref cm-footnote-ref-missing" });
62
+ /** 각주가 올 수 없으므로 자식 순회를 생략하는 노드 */
63
+ const SKIP_CHILDREN = new Set([
64
+ "FencedCode",
65
+ "CodeBlock",
66
+ "HTMLBlock",
67
+ "CommentBlock",
68
+ "InlineCode",
69
+ "URL",
70
+ "Autolink",
71
+ "LinkReference",
72
+ ]);
73
+ /** FootnoteReference/FootnoteDefinition의 [여는 기호, 라벨, 닫는 기호] */
74
+ function partsOf(node) {
75
+ const marks = node.getChildren("FootnoteMark");
76
+ const label = node.getChild("FootnoteLabel");
77
+ return label && marks.length >= 2 ? [marks[0], label, marks[1]] : null;
78
+ }
79
+ export const footnotePlugin = createRevealPlugin({
80
+ // 정의 줄 데코레이션과 기호/참조 데코레이션이 섞일 수 있으므로 정렬합니다.
81
+ sort: true,
82
+ visit({ state, decorations, isRevealed }) {
83
+ const defs = footnoteDefinitions(state);
84
+ return {
85
+ enter(node) {
86
+ if (node.name === "FootnoteReference") {
87
+ const parts = partsOf(node.node);
88
+ if (!parts)
89
+ return false;
90
+ const [open, label, close] = parts;
91
+ if (isRevealed(node.from, node.to)) {
92
+ decorations.push(formatting.range(open.from, open.to), revealedRefLabel.range(label.from, label.to), formatting.range(close.from, close.to));
93
+ }
94
+ else {
95
+ const def = defs.get(normalize(state.doc.sliceString(label.from, label.to)));
96
+ decorations.push(hidden.range(open.from, open.to), (def ? refLabel : refLabelMissing).range(label.from, label.to), hidden.range(close.from, close.to));
97
+ }
98
+ return false;
99
+ }
100
+ if (node.name === "FootnoteDefinition") {
101
+ const parts = partsOf(node.node);
102
+ if (!parts)
103
+ return false;
104
+ const [open, label, close] = parts;
105
+ // 정의의 모든 줄(이어지는 줄 포함)에 정의 스타일을 적용하고,
106
+ // 연속된 정의 묶음의 첫 줄에는 본문과 구분하는 구분선을 그립니다.
107
+ const { doc } = state;
108
+ const groupStart = node.node.prevSibling?.name !== "FootnoteDefinition";
109
+ const firstLine = doc.lineAt(node.from).number;
110
+ const lastLine = doc.lineAt(node.to).number;
111
+ for (let n = firstLine; n <= lastLine; n++) {
112
+ const deco = n === firstLine && groupStart ? defLineGroupStart : defLine;
113
+ decorations.push(deco.range(doc.line(n).from));
114
+ }
115
+ // 내용을 입력하는 동안 기호가 펼쳐지지 않도록, 라벨 부분([^label]:)에 닿을 때만 원문을 보여줍니다.
116
+ if (isRevealed(open.from, close.to)) {
117
+ decorations.push(formatting.range(open.from, open.to), revealedRefLabel.range(label.from, label.to), formatting.range(close.from, close.to));
118
+ }
119
+ else {
120
+ decorations.push(hidden.range(open.from, open.to), defLabel.range(label.from, label.to), hidden.range(close.from, close.to));
121
+ }
122
+ return; // 정의 내용 안의 각주 참조도 처리합니다.
123
+ }
124
+ if (SKIP_CHILDREN.has(node.name))
125
+ return false;
126
+ },
127
+ };
128
+ },
129
+ });
130
+ // ---------------------------------------------------------------------------
131
+ // 툴팁: 정의 내용을 서식과 함께 보여줍니다.
132
+ // 별도 마크다운 라이브러리 대신 이미 파싱된 구문 트리로 DOM을 만듭니다.
133
+ // (==하이라이트==, --밑줄--, 해시태그 등 이 프로젝트의 확장 문법도 그대로 반영)
134
+ // ---------------------------------------------------------------------------
135
+ /** 툴팁에서 그리지 않는 기호 노드 */
136
+ const TOOLTIP_SKIP = new Set([
137
+ "EmphasisMark",
138
+ "CodeMark",
139
+ "LinkMark",
140
+ "HighlightMark",
141
+ "UnderlineMark",
142
+ "StrikethroughMark",
143
+ "SuperscriptMark",
144
+ "SubscriptMark",
145
+ "FootnoteMark",
146
+ "URL",
147
+ "LinkTitle",
148
+ "LinkLabel",
149
+ ]);
150
+ /** 노드 → HTML 태그 */
151
+ const TOOLTIP_TAGS = {
152
+ StrongEmphasis: "strong",
153
+ Emphasis: "em",
154
+ Strikethrough: "s",
155
+ Highlight: "mark",
156
+ Underline: "u",
157
+ InlineCode: "code",
158
+ Superscript: "sup",
159
+ Subscript: "sub",
160
+ };
161
+ /** 마크다운의 줄바꿈(소프트 줄바꿈)은 공백으로 보여줍니다. */
162
+ const appendText = (parent, text) => {
163
+ if (text)
164
+ parent.appendChild(document.createTextNode(text.replace(/[ \t]*\n[ \t]*/g, " ")));
165
+ };
166
+ /** node의 [from, to] 범위를 자식 노드와 그 사이 글자로 나눠 그립니다. */
167
+ function renderRange(state, node, from, to, parent) {
168
+ let pos = from;
169
+ for (let child = node.firstChild; child; child = child.nextSibling) {
170
+ if (child.to <= from || child.from >= to)
171
+ continue;
172
+ if (child.from > pos)
173
+ appendText(parent, state.doc.sliceString(pos, child.from));
174
+ renderNode(state, child, parent);
175
+ pos = Math.max(pos, child.to);
176
+ }
177
+ if (pos < to)
178
+ appendText(parent, state.doc.sliceString(pos, to));
179
+ }
180
+ function renderNode(state, node, parent) {
181
+ const { name } = node;
182
+ if (TOOLTIP_SKIP.has(name))
183
+ return;
184
+ if (name === "Escape")
185
+ return appendText(parent, state.doc.sliceString(node.from + 1, node.to));
186
+ if (name === "HardBreak")
187
+ return void parent.appendChild(document.createElement("br"));
188
+ if (name === "FootnoteReference") {
189
+ const label = node.getChild("FootnoteLabel");
190
+ const sup = document.createElement("sup");
191
+ sup.className = "cm-footnote-tooltip-ref";
192
+ if (label)
193
+ sup.textContent = state.doc.sliceString(label.from, label.to);
194
+ return void parent.appendChild(sup);
195
+ }
196
+ if (name === "Link") {
197
+ const link = readInlineLink(state, node);
198
+ const a = document.createElement("a");
199
+ a.className = "cm-footnote-tooltip-link";
200
+ // 안전한 주소만 링크로 만듭니다. (javascript: 등은 글자만 표시)
201
+ if (link && isSafeUrl(link.href)) {
202
+ a.href = link.href;
203
+ a.target = "_blank";
204
+ a.rel = "noopener noreferrer";
205
+ }
206
+ if (link)
207
+ renderRange(state, node, link.labelFrom, link.labelTo, a);
208
+ else
209
+ renderRange(state, node, node.from, node.to, a);
210
+ return void parent.appendChild(a);
211
+ }
212
+ if (name === "Image") {
213
+ // 툴팁에는 이미지 대신 캡션(대체 텍스트)을 보여줍니다.
214
+ const image = readImage(state, node);
215
+ const span = document.createElement("span");
216
+ span.className = "cm-footnote-tooltip-image";
217
+ span.textContent = image?.caption || "이미지";
218
+ return void parent.appendChild(span);
219
+ }
220
+ const el = document.createElement(TOOLTIP_TAGS[name] ?? "span");
221
+ if (!TOOLTIP_TAGS[name])
222
+ el.className = `cm-footnote-tooltip-${name.toLowerCase()}`;
223
+ renderRange(state, node, node.from, node.to, el);
224
+ parent.appendChild(el);
225
+ }
226
+ function definitionNode(state, def) {
227
+ for (let n = syntaxTree(state).resolveInner(def.from, 1); n; n = n.parent) {
228
+ if (n.name === "FootnoteDefinition")
229
+ return n;
230
+ }
231
+ return null;
232
+ }
233
+ /** 각주 정의 내용을 서식이 반영된 DOM으로 그립니다. 정의가 없으면 null */
234
+ export function renderFootnoteDefinition(state, label) {
235
+ const def = footnoteDefinitions(state).get(normalize(label));
236
+ const node = def && definitionNode(state, def);
237
+ if (!def || !node)
238
+ return null;
239
+ const dom = document.createElement("div");
240
+ dom.className = "cm-footnote-tooltip-content";
241
+ renderRange(state, node, def.contentFrom, def.to, dom);
242
+ return dom;
243
+ }
244
+ /** pos(마우스 위치)에 각주 참조가 있으면 그 정의를 보여주는 툴팁 */
245
+ export function footnoteTooltipAt(state, pos, side = 1) {
246
+ let ref = null;
247
+ for (let n = syntaxTree(state).resolveInner(pos, side); n; n = n.parent) {
248
+ if (n.name === "FootnoteReference") {
249
+ ref = n;
250
+ break;
251
+ }
252
+ }
253
+ if (!ref)
254
+ return null;
255
+ const label = labelOf(state, ref) ?? "";
256
+ return {
257
+ pos: ref.from,
258
+ end: ref.to,
259
+ above: true,
260
+ create() {
261
+ const dom = document.createElement("div");
262
+ dom.className = "cm-footnote-tooltip";
263
+ const content = renderFootnoteDefinition(state, label);
264
+ if (content) {
265
+ dom.appendChild(content);
266
+ }
267
+ else {
268
+ dom.classList.add("cm-footnote-tooltip-missing");
269
+ dom.textContent = "정의되지 않은 각주";
270
+ }
271
+ return { dom };
272
+ },
273
+ };
274
+ }
275
+ const footnoteHover = hoverTooltip((view, pos, side) => footnoteTooltipAt(view.state, pos, side), {
276
+ hideOnChange: true,
277
+ });
278
+ // ---------------------------------------------------------------------------
279
+ // 클릭: 참조 → 정의, 정의 라벨 → 첫 참조
280
+ // ---------------------------------------------------------------------------
281
+ /** pos 위치의 노드에서 위로 올라가며 name인 노드를 찾습니다. */
282
+ function ancestor(state, pos, name) {
283
+ for (let n = syntaxTree(state).resolveInner(pos, 1); n; n = n.parent) {
284
+ if (n.name === name)
285
+ return n;
286
+ }
287
+ return null;
288
+ }
289
+ function labelOf(state, node) {
290
+ const label = node.getChild("FootnoteLabel");
291
+ return label ? normalize(state.doc.sliceString(label.from, label.to)) : null;
292
+ }
293
+ /** 라벨의 첫 참조 위치 (클릭할 때만 문서 전체를 훑습니다) */
294
+ function firstReference(state, label) {
295
+ let found = null;
296
+ syntaxTree(state).iterate({
297
+ enter(node) {
298
+ if (found)
299
+ return false;
300
+ if (SKIP_CHILDREN.has(node.name))
301
+ return false;
302
+ if (node.name === "FootnoteReference") {
303
+ if (labelOf(state, node.node) === label)
304
+ found = node.node;
305
+ return false;
306
+ }
307
+ },
308
+ });
309
+ return found;
310
+ }
311
+ function jump(view, pos) {
312
+ view.dispatch({
313
+ selection: EditorSelection.cursor(pos),
314
+ effects: EditorView.scrollIntoView(pos, { y: "center" }),
315
+ userEvent: "select.footnote",
316
+ });
317
+ view.focus();
318
+ }
319
+ const footnoteEvents = EditorView.domEventHandlers({
320
+ mousedown(event, view) {
321
+ if (event.button !== 0)
322
+ return false;
323
+ const target = event.target;
324
+ const ref = target?.closest?.(".cm-footnote-ref");
325
+ const def = target?.closest?.(".cm-footnote-def-label");
326
+ const el = ref ?? def;
327
+ if (!el || !view.contentDOM.contains(el))
328
+ return false;
329
+ const { state } = view;
330
+ const pos = view.posAtDOM(el);
331
+ if (ref) {
332
+ // 참조 → 정의 내용 시작으로 이동
333
+ const node = ancestor(state, pos, "FootnoteReference");
334
+ const label = node && labelOf(state, node);
335
+ const target = label ? footnoteDefinitions(state).get(label) : undefined;
336
+ if (!target)
337
+ return false; // 정의가 없으면 일반 클릭(커서 이동)
338
+ event.preventDefault();
339
+ jump(view, target.contentFrom);
340
+ return true;
341
+ }
342
+ // 정의 라벨 → 첫 참조 뒤로 이동
343
+ const node = ancestor(state, pos, "FootnoteDefinition");
344
+ const label = node && labelOf(state, node);
345
+ const reference = label ? firstReference(state, label) : null;
346
+ if (!reference)
347
+ return false;
348
+ event.preventDefault();
349
+ jump(view, reference.to);
350
+ return true;
351
+ },
352
+ });
353
+ // ---------------------------------------------------------------------------
354
+ // 테마
355
+ // ---------------------------------------------------------------------------
356
+ const footnoteTheme = EditorView.baseTheme({
357
+ ".cm-footnote-ref": {
358
+ verticalAlign: "super",
359
+ fontSize: "0.75em",
360
+ lineHeight: "0",
361
+ fontWeight: "bold",
362
+ margin: "0 2px",
363
+ cursor: "pointer",
364
+ },
365
+ "&light .cm-footnote-ref": { color: "#2563eb" },
366
+ "&dark .cm-footnote-ref": { color: "#60a5fa" },
367
+ ".cm-footnote-ref-missing": {
368
+ color: "#dc2626 !important",
369
+ cursor: "text",
370
+ },
371
+ ".cm-footnote-def-label": {
372
+ fontWeight: "bold",
373
+ cursor: "pointer",
374
+ marginRight: "0.5em",
375
+ },
376
+ ".cm-footnote-def-label::after": {
377
+ content: '"."',
378
+ },
379
+ // 정의: 본문보다 살짝 작은 글자, 묶음 첫 줄 위에 구분선.
380
+ // CodeMirror는 줄 높이를 직접 재므로 줄에 margin을 쓸 수 없습니다. 그래서 위쪽 padding으로 자리를 만들고,
381
+ // 구분선은 배경으로 그 가운데쯤(위 본문과 간격을 두고)에 그립니다.
382
+ ".cm-footnote-def-line": {
383
+ fontSize: "0.875em",
384
+ },
385
+ "&light .cm-footnote-def-line": { color: "#4b5563" },
386
+ "&dark .cm-footnote-def-line": { color: "#9ca3af" },
387
+ ".cm-footnote-defs-start": {
388
+ paddingTop: "1.25em",
389
+ backgroundRepeat: "no-repeat",
390
+ backgroundSize: "100% 1px",
391
+ backgroundPosition: "0 0.75em",
392
+ },
393
+ // 거터의 글자 크기는 본문 기준이므로 정의 글자 크기(0.875em)를 곱한 값으로 맞춥니다.
394
+ ".cm-gutterElement.cm-footnote-defs-start-gutter": { paddingTop: "calc(1.25em * 0.875)" },
395
+ "&light .cm-footnote-defs-start": { backgroundImage: "linear-gradient(#e5e7eb, #e5e7eb)" },
396
+ "&dark .cm-footnote-defs-start": { backgroundImage: "linear-gradient(#374151, #374151)" },
397
+ // 툴팁
398
+ ".cm-tooltip.cm-footnote-tooltip": {
399
+ padding: "6px 10px",
400
+ maxWidth: "360px",
401
+ fontSize: "0.875em",
402
+ lineHeight: "1.5",
403
+ whiteSpace: "normal",
404
+ },
405
+ ".cm-footnote-tooltip code": {
406
+ fontFamily: "monospace",
407
+ backgroundColor: "rgba(135, 131, 120, 0.15)",
408
+ borderRadius: "3px",
409
+ padding: "0 3px",
410
+ },
411
+ ".cm-footnote-tooltip mark": {
412
+ backgroundColor: "rgba(255, 177, 80, 0.3)",
413
+ color: "inherit",
414
+ },
415
+ ".cm-footnote-tooltip-missing": {
416
+ color: "#dc2626",
417
+ },
418
+ });
419
+ /**
420
+ * 각주(`[^label]` 참조, `[^label]: 내용` 정의)를 하이브리드 방식으로 보여주는 확장입니다.
421
+ * - 참조는 라벨을 위첨자로 보여주고, 마우스를 올리면 정의 내용을 서식이 반영된 툴팁으로 보여줍니다.
422
+ * - 정의는 본문보다 작은 글자로 보여주고, 연속된 정의 묶음 위에 구분선을 그립니다.
423
+ * 정의가 없는 참조는 별도 스타일(cm-footnote-ref-missing)로 표시합니다.
424
+ * - 참조를 클릭하면 정의로, 정의 라벨을 클릭하면 첫 참조로 이동합니다.
425
+ * - 커서가 기호에 닿으면 원문을 보여줍니다. (정의는 라벨 부분 `[^label]:`에 닿을 때)
426
+ * - extendedMarkdown()에 포함된 Footnote 문법이 필요합니다.
427
+ */
428
+ // ---------------------------------------------------------------------------
429
+ // 거터: 정의 묶음 첫 줄은 위쪽 여백(구분선 자리)만큼 줄 번호도 내립니다.
430
+ // ---------------------------------------------------------------------------
431
+ const defsStartGutterMarker = new (class extends GutterMarker {
432
+ elementClass = "cm-footnote-defs-start-gutter";
433
+ })();
434
+ /** 정의 묶음 첫 줄들 (정의는 문서 최상위에만 있으므로 최상위 블록만 훑습니다) */
435
+ function defsStartLines(state) {
436
+ const ranges = [];
437
+ const cursor = syntaxTree(state).cursor();
438
+ let prevIsDef = false;
439
+ if (cursor.firstChild()) {
440
+ do {
441
+ const isDef = cursor.type.is("FootnoteDefinition");
442
+ if (isDef && !prevIsDef)
443
+ ranges.push(defsStartGutterMarker.range(state.doc.lineAt(cursor.from).from));
444
+ prevIsDef = isDef;
445
+ } while (cursor.nextSibling());
446
+ }
447
+ return RangeSet.of(ranges);
448
+ }
449
+ const defsStartGutter = StateField.define({
450
+ create: defsStartLines,
451
+ update: (value, tr) => tr.docChanged || syntaxTree(tr.state) !== syntaxTree(tr.startState) ? defsStartLines(tr.state) : value,
452
+ provide: (field) => gutterLineClass.from(field),
453
+ });
454
+ export function footnote() {
455
+ return [footnotePlugin, defsStartGutter, footnoteEvents, footnoteHover, footnoteTheme];
456
+ }
@@ -0,0 +1,23 @@
1
+ import type { MarkdownConfig } from "@lezer/markdown";
2
+ /**
3
+ * 각주 문법입니다.
4
+ *
5
+ * 참조 `[^label]`:
6
+ * ```
7
+ * FootnoteReference
8
+ * FootnoteMark "[^"
9
+ * FootnoteLabel "label"
10
+ * FootnoteMark "]"
11
+ * ```
12
+ * 정의 `[^label]: 내용` (최상위 문단, 내용은 인라인 문법으로 파싱):
13
+ * ```
14
+ * FootnoteDefinition
15
+ * FootnoteMark "[^"
16
+ * FootnoteLabel "label"
17
+ * FootnoteMark "]:"
18
+ * ...내용
19
+ * ```
20
+ * - 라벨: 공백, `[`, `]`, `^`를 제외한 문자 (`1`, `note-1`, `각주` 등)
21
+ * - 정의는 하나의 문단입니다. (빈 줄 뒤 들여쓴 여러 문단 각주는 지원하지 않습니다)
22
+ */
23
+ export declare const Footnote: MarkdownConfig;
@@ -0,0 +1,107 @@
1
+ const OpenBracket = 91; // "["
2
+ const CloseBracket = 93; // "]"
3
+ const Caret = 94; // "^"
4
+ /** 라벨에 올 수 없는 문자: 공백, [, ], ^ */
5
+ function isLabelBreak(code) {
6
+ if (code === OpenBracket || code === CloseBracket || code === Caret)
7
+ return true;
8
+ if (code <= 32)
9
+ return true;
10
+ return code >= 128 && /\s/.test(String.fromCharCode(code));
11
+ }
12
+ /** 정의: 문단이 `[^label]:`로 시작 */
13
+ const DEFINITION_RE = /^\[\^([^\s[\]^]+)\]:/;
14
+ class FootnoteDefinitionParser {
15
+ nextLine(_cx, _line, leaf) {
16
+ // 각주 정의 문단은 이 파서가 맡습니다. 뒤에 등록된 파서(링크 참조 정의 등)가
17
+ // 다음 줄에서 블록을 먼저 끝내지 않도록 제거합니다. (이 파서가 먼저 호출되므로 제거된 파서는 호출되지 않음)
18
+ if (leaf.parsers.length > 1)
19
+ leaf.parsers.splice(0, leaf.parsers.length, this);
20
+ // 이어지는 줄(빈 줄 전까지)은 모두 정의 내용입니다.
21
+ return false;
22
+ }
23
+ finish(cx, leaf) {
24
+ const match = DEFINITION_RE.exec(leaf.content);
25
+ if (!match)
26
+ return false;
27
+ const start = leaf.start;
28
+ const labelTo = start + 2 + match[1].length;
29
+ const markTo = labelTo + 2; // "]:"
30
+ cx.addLeafElement(leaf, cx.elt("FootnoteDefinition", start, start + leaf.content.length, [
31
+ cx.elt("FootnoteMark", start, start + 2),
32
+ cx.elt("FootnoteLabel", start + 2, labelTo),
33
+ cx.elt("FootnoteMark", labelTo, markTo),
34
+ ...cx.parser.parseInline(leaf.content.slice(markTo - start), markTo),
35
+ ]));
36
+ return true;
37
+ }
38
+ }
39
+ /**
40
+ * 각주 문법입니다.
41
+ *
42
+ * 참조 `[^label]`:
43
+ * ```
44
+ * FootnoteReference
45
+ * FootnoteMark "[^"
46
+ * FootnoteLabel "label"
47
+ * FootnoteMark "]"
48
+ * ```
49
+ * 정의 `[^label]: 내용` (최상위 문단, 내용은 인라인 문법으로 파싱):
50
+ * ```
51
+ * FootnoteDefinition
52
+ * FootnoteMark "[^"
53
+ * FootnoteLabel "label"
54
+ * FootnoteMark "]:"
55
+ * ...내용
56
+ * ```
57
+ * - 라벨: 공백, `[`, `]`, `^`를 제외한 문자 (`1`, `note-1`, `각주` 등)
58
+ * - 정의는 하나의 문단입니다. (빈 줄 뒤 들여쓴 여러 문단 각주는 지원하지 않습니다)
59
+ */
60
+ export const Footnote = {
61
+ defineNodes: [
62
+ { name: "FootnoteReference" },
63
+ { name: "FootnoteDefinition", block: true },
64
+ { name: "FootnoteMark" },
65
+ { name: "FootnoteLabel" },
66
+ ],
67
+ parseInline: [
68
+ {
69
+ name: "FootnoteReference",
70
+ parse(cx, next, pos) {
71
+ // 핫 패스: 모든 문자 위치마다 호출되므로 첫 비교로 즉시 빠져나갑니다.
72
+ if (next !== OpenBracket || cx.char(pos + 1) !== Caret)
73
+ return -1;
74
+ let end = pos + 2;
75
+ while (end < cx.end && !isLabelBreak(cx.char(end)))
76
+ end++;
77
+ if (end === pos + 2 || cx.char(end) !== CloseBracket)
78
+ return -1;
79
+ return cx.addElement(cx.elt("FootnoteReference", pos, end + 1, [
80
+ cx.elt("FootnoteMark", pos, pos + 2),
81
+ cx.elt("FootnoteLabel", pos + 2, end),
82
+ cx.elt("FootnoteMark", end, end + 1),
83
+ ]));
84
+ },
85
+ // 링크(`[`)보다 먼저 처리해야 `[^1]`이 링크로 해석되지 않습니다.
86
+ before: "Link",
87
+ },
88
+ ],
89
+ parseBlock: [
90
+ {
91
+ name: "FootnoteDefinition",
92
+ leaf(cx, leaf) {
93
+ // 정의는 최상위 문단에서만 인식합니다.
94
+ return cx.parentType().name === "Document" && DEFINITION_RE.test(leaf.content)
95
+ ? new FootnoteDefinitionParser()
96
+ : null;
97
+ },
98
+ // 빈 줄 없이 연속한 정의(`[^1]: …` 다음 줄에 `[^2]: …`)를 따로 인식하도록,
99
+ // `[^label]:`로 시작하는 줄이 앞 문단(또는 앞 정의)을 끝내게 합니다.
100
+ endLeaf(cx, line) {
101
+ return cx.parentType().name === "Document" && DEFINITION_RE.test(line.text.slice(line.pos));
102
+ },
103
+ // 링크 참조 정의(`[label]: url`)보다 먼저 처리합니다.
104
+ before: "LinkReference",
105
+ },
106
+ ],
107
+ };
@@ -0,0 +1,4 @@
1
+ import type { Extension } from "@codemirror/state";
2
+ export declare const hashtagPlugin: import("@codemirror/view").ViewPlugin<import("./extensions.js").RevealPluginValue, undefined>;
3
+ /** `#tag` 해시태그에 스타일을 적용하는 확장입니다. */
4
+ export declare function hashtag(): Extension;
@@ -0,0 +1,64 @@
1
+ import { Decoration, EditorView } from "@codemirror/view";
2
+ import { createRevealPlugin } from "./utils/reveal.js";
3
+ // 데코레이션 객체는 모듈 로드 시 한 번만 만들고 재사용합니다.
4
+ const begin = Decoration.mark({ class: "cm-hashtag cm-hashtag-begin" });
5
+ const end = Decoration.mark({ class: "cm-hashtag cm-hashtag-end" });
6
+ const beginRevealed = Decoration.mark({
7
+ class: "cm-formatting cm-formatting-hashtag cm-hashtag cm-hashtag-begin",
8
+ });
9
+ const endRevealed = Decoration.mark({
10
+ class: "cm-formatting cm-formatting-hashtag cm-hashtag cm-hashtag-end",
11
+ });
12
+ /** 계층 구분자 '/' (테마에서 따로 꾸미기 위한 훅) */
13
+ const separator = Decoration.mark({ class: "cm-hashtag-separator" });
14
+ /** 해시태그가 올 수 없으므로 자식 순회를 생략하는 노드 */
15
+ const SKIP_CHILDREN = new Set([
16
+ "FencedCode",
17
+ "CodeBlock",
18
+ "InlineCode",
19
+ "HTMLBlock",
20
+ "CommentBlock",
21
+ "URL",
22
+ "Autolink",
23
+ ]);
24
+ export const hashtagPlugin = createRevealPlugin({
25
+ // 문서 순서대로(태그 시작 → 본문 → 본문 안의 구분자) 추가되므로 정렬이 필요 없습니다.
26
+ visit({ decorations, isRevealed }) {
27
+ return {
28
+ enter(node) {
29
+ if (node.name === "Hashtag") {
30
+ const revealed = isRevealed(node.from, node.to);
31
+ // 파서가 HashtagMark를 항상 '#' 한 글자로 만듭니다.
32
+ decorations.push((revealed ? beginRevealed : begin).range(node.from, node.from + 1), (revealed ? endRevealed : end).range(node.from + 1, node.to));
33
+ return; // 계층 구분자를 찾기 위해 자식을 순회합니다.
34
+ }
35
+ if (node.name === "HashtagSeparator") {
36
+ decorations.push(separator.range(node.from, node.to));
37
+ return false;
38
+ }
39
+ if (SKIP_CHILDREN.has(node.name))
40
+ return false;
41
+ },
42
+ };
43
+ },
44
+ });
45
+ const hashtagTheme = EditorView.baseTheme({
46
+ ".cm-hashtag": {
47
+ backgroundColor: "#008CFF",
48
+ // `.cm-hashtag *`로 지정하면 안쪽 span이 있을 때만 적용되어
49
+ // (구문 강조 span이 없으면 적용되지 않음) 글자 크기가 들쭉날쭉해지므로 직접 지정합니다.
50
+ fontSize: "0.75rem",
51
+ },
52
+ ".cm-hashtag.cm-hashtag-begin": {
53
+ borderRadius: "2px 0 0 2px",
54
+ padding: "1px 0 1px 4px",
55
+ },
56
+ ".cm-hashtag.cm-hashtag-end": {
57
+ borderRadius: "0 2px 2px 0",
58
+ padding: "1px 4px 1px 0",
59
+ },
60
+ });
61
+ /** `#tag` 해시태그에 스타일을 적용하는 확장입니다. */
62
+ export function hashtag() {
63
+ return [hashtagPlugin, hashtagTheme];
64
+ }