mdsquare 0.0.0-stage → 0.2.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 +218 -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 +132 -0
  14. package/dist/editor.js +225 -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 +74 -0
  20. package/dist/extensions.js +81 -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,36 @@
1
+ import type { MarkdownConfig } from "@lezer/markdown";
2
+ import { Tag } from "@lezer/highlight";
3
+ /** `#tag` 해시태그에 사용할 하이라이트 태그 */
4
+ export declare const hashtagTag: Tag;
5
+ /**
6
+ * `#tag`, `#부모/자식` 인라인 해시태그 문법입니다.
7
+ *
8
+ * 노드 구조:
9
+ * ```
10
+ * Hashtag
11
+ * HashtagMark "#"
12
+ * HashtagSegment "부모"
13
+ * HashtagSeparator "/"
14
+ * HashtagSegment "자식"
15
+ * ```
16
+ *
17
+ * - `#` 앞: 인라인 구간의 시작(줄 시작 등) 또는 공백만 허용 (`a#b`, `(#tag)`는 해시태그가 아님)
18
+ * - 세그먼트: Unicode 문자/숫자/결합 문자와 `_`, `-`
19
+ * - 계층: 세그먼트를 `/`로 구분하며 빈 세그먼트는 허용하지 않습니다.
20
+ * (`#/a`는 해시태그가 아니고, `#a/`, `#a//b`는 `#a`까지만 인식)
21
+ * - 태그 전체에 문자(letter)가 최소 1개 있어야 합니다. (`#123`은 아님, `#2024/회의`는 허용)
22
+ */
23
+ export declare const Hashtag: MarkdownConfig;
24
+ export interface HashtagInfo {
25
+ /** `#`을 뺀 전체 이름 (예: "부모/자식") */
26
+ readonly name: string;
27
+ /** 계층별 세그먼트 (예: ["부모", "자식"]) */
28
+ readonly segments: readonly string[];
29
+ /** 자신을 포함한 상위 태그 목록, 상위부터 (예: ["부모", "부모/자식"]) */
30
+ readonly ancestors: readonly string[];
31
+ }
32
+ /**
33
+ * 해시태그 노드의 텍스트(예: `#부모/자식`)에서 계층 정보를 추출합니다.
34
+ * 파서가 형식(빈 세그먼트 없음)을 보장하므로 단순 분리로 충분합니다.
35
+ */
36
+ export declare function parseHashtag(text: string): HashtagInfo;
@@ -0,0 +1,99 @@
1
+ import { Tag, tags } from "@lezer/highlight";
2
+ import { codePointAt, codePointBefore, createClassifier, sizeOf } from "./utils/codePoint.js";
3
+ /** `#tag` 해시태그에 사용할 하이라이트 태그 */
4
+ export const hashtagTag = Tag.define(tags.content);
5
+ const Hash = 35; // "#"
6
+ const Slash = 47; // "/"
7
+ // ---------------------------------------------------------------------------
8
+ // 문자 분류
9
+ // - ASCII는 테이블로 O(1) 조회, 그 외에는 Unicode 정규식으로 판정 (utils/codePoint.ts)
10
+ // - 코드 포인트 단위로 판정합니다. (서로게이트 쌍 문자도 올바르게 처리)
11
+ // ---------------------------------------------------------------------------
12
+ const SEGMENT = 1; // 세그먼트(계층의 한 단계)에 올 수 있는 문자
13
+ const LETTER = 2; // 문자(letter). 태그 전체에 최소 1개 필요 (#123 같은 숫자만은 제외)
14
+ const SPACE = 4; // '#' 앞에 올 수 있는 공백
15
+ const SegmentRe = /[\p{L}\p{N}\p{M}_-]/u;
16
+ const LetterRe = /\p{L}/u;
17
+ const SpaceRe = /\s/u;
18
+ const flagsOf = createClassifier((ch) => (SegmentRe.test(ch) ? SEGMENT : 0) |
19
+ (LetterRe.test(ch) ? LETTER : 0) |
20
+ (SpaceRe.test(ch) ? SPACE : 0));
21
+ /**
22
+ * `#tag`, `#부모/자식` 인라인 해시태그 문법입니다.
23
+ *
24
+ * 노드 구조:
25
+ * ```
26
+ * Hashtag
27
+ * HashtagMark "#"
28
+ * HashtagSegment "부모"
29
+ * HashtagSeparator "/"
30
+ * HashtagSegment "자식"
31
+ * ```
32
+ *
33
+ * - `#` 앞: 인라인 구간의 시작(줄 시작 등) 또는 공백만 허용 (`a#b`, `(#tag)`는 해시태그가 아님)
34
+ * - 세그먼트: Unicode 문자/숫자/결합 문자와 `_`, `-`
35
+ * - 계층: 세그먼트를 `/`로 구분하며 빈 세그먼트는 허용하지 않습니다.
36
+ * (`#/a`는 해시태그가 아니고, `#a/`, `#a//b`는 `#a`까지만 인식)
37
+ * - 태그 전체에 문자(letter)가 최소 1개 있어야 합니다. (`#123`은 아님, `#2024/회의`는 허용)
38
+ */
39
+ export const Hashtag = {
40
+ defineNodes: [
41
+ { name: "Hashtag", style: { "Hashtag/...": hashtagTag } },
42
+ // 기호에는 processingInstruction 등의 색을 따로 주지 않습니다.
43
+ // (구문 강조가 안쪽 span에 색을 직접 지정해 테마 색을 덮어쓰는 문제 방지)
44
+ { name: "HashtagMark" },
45
+ { name: "HashtagSegment" },
46
+ { name: "HashtagSeparator" },
47
+ ],
48
+ parseInline: [
49
+ {
50
+ name: "Hashtag",
51
+ parse(cx, next, pos) {
52
+ // 핫 패스: 모든 문자 위치마다 호출되므로 첫 비교로 즉시 빠져나갑니다.
53
+ if (next !== Hash)
54
+ return -1;
55
+ const before = codePointBefore(cx, pos);
56
+ if (before >= 0 && !(flagsOf(before) & SPACE))
57
+ return -1;
58
+ const children = [cx.elt("HashtagMark", pos, pos + 1)];
59
+ let hasLetter = false;
60
+ let segmentStart = pos + 1;
61
+ let end = pos + 1;
62
+ for (;;) {
63
+ // 세그먼트 하나를 읽습니다.
64
+ let cp = codePointAt(cx, end);
65
+ let flags = flagsOf(cp);
66
+ while (flags & SEGMENT) {
67
+ if (flags & LETTER)
68
+ hasLetter = true;
69
+ end += sizeOf(cp);
70
+ cp = codePointAt(cx, end);
71
+ flags = flagsOf(cp);
72
+ }
73
+ if (end === segmentStart)
74
+ break; // 빈 세그먼트 (첫 세그먼트면 해시태그 아님)
75
+ children.push(cx.elt("HashtagSegment", segmentStart, end));
76
+ // 다음 세그먼트가 이어지는 '/'만 구분자로 받습니다. ('/' 끝, '//'는 제외)
77
+ if (cp !== Slash || !(flagsOf(codePointAt(cx, end + 1)) & SEGMENT))
78
+ break;
79
+ children.push(cx.elt("HashtagSeparator", end, end + 1));
80
+ segmentStart = end = end + 1;
81
+ }
82
+ if (!hasLetter)
83
+ return -1;
84
+ return cx.addElement(cx.elt("Hashtag", pos, end, children));
85
+ },
86
+ after: "Emphasis",
87
+ },
88
+ ],
89
+ };
90
+ /**
91
+ * 해시태그 노드의 텍스트(예: `#부모/자식`)에서 계층 정보를 추출합니다.
92
+ * 파서가 형식(빈 세그먼트 없음)을 보장하므로 단순 분리로 충분합니다.
93
+ */
94
+ export function parseHashtag(text) {
95
+ const name = text.startsWith("#") ? text.slice(1) : text;
96
+ const segments = name.split("/");
97
+ const ancestors = segments.map((_, i) => segments.slice(0, i + 1).join("/"));
98
+ return { name, segments, ancestors };
99
+ }
@@ -0,0 +1,7 @@
1
+ import type { Extension } from "@codemirror/state";
2
+ export declare const headingPlugin: import("@codemirror/view").ViewPlugin<import("./extensions.js").RevealPluginValue, undefined>;
3
+ /**
4
+ * 제목(ATX `#`, Setext `===`/`---`)에 스타일을 적용하고,
5
+ * `#` 기호는 커서가 해당 제목에 닿아 있을 때만 보여주는 확장입니다.
6
+ */
7
+ export declare function heading(): Extension;
@@ -0,0 +1,159 @@
1
+ import { Decoration, EditorView } from "@codemirror/view";
2
+ import { createRevealPlugin } from "./utils/reveal.js";
3
+ const HEADINGS = new Map([
4
+ ...[1, 2, 3, 4, 5, 6].map((level) => [`ATXHeading${level}`, { setext: false, level }]),
5
+ ...[1, 2].map((level) => [`SetextHeading${level}`, { setext: true, level }]),
6
+ ]);
7
+ /**
8
+ * 제목(블록)을 포함할 수 있는 컨테이너 블록입니다.
9
+ * 이 외의 노드(문단, 인라인 요소, 코드 블록 등)는 자식 순회를 생략합니다.
10
+ */
11
+ const CONTAINERS = new Set([
12
+ "Document",
13
+ "Blockquote",
14
+ "BulletList",
15
+ "OrderedList",
16
+ "ListItem",
17
+ ]);
18
+ // 데코레이션 객체는 모듈 로드 시 한 번만 만들고 재사용합니다. (index = level)
19
+ const levels = [0, 1, 2, 3, 4, 5, 6];
20
+ const headingLine = levels.map((l) => Decoration.line({ class: `cm-heading cm-heading-${l}` }));
21
+ const atxFormatting = levels.map((l) => Decoration.mark({
22
+ class: `cm-formatting cm-formatting-heading cm-formatting-heading-${l} cm-heading cm-heading-${l}`,
23
+ }));
24
+ const setextUnderline = levels.map((l) => Decoration.line({ class: `cm-heading-line cm-heading-line-${l}` }));
25
+ const setextUnderlineRevealed = levels.map((l) => Decoration.line({
26
+ class: `cm-formatting cm-formatting-heading-line cm-formatting-heading-line-${l} cm-heading-line cm-heading-line-${l}`,
27
+ }));
28
+ const hiddenMark = Decoration.replace({});
29
+ const isBlank = (code) => code === 32 /* space */ || code === 9; /* tab */
30
+ // ---------------------------------------------------------------------------
31
+ // 데코레이션 생성
32
+ // ---------------------------------------------------------------------------
33
+ /** `# 제목 #` 형태 */
34
+ function decorateAtx(out, doc, node, level, revealed) {
35
+ // 들여쓰기/인용/리스트 안의 제목은 node.from이 줄 시작이 아니므로 줄 시작을 따로 구합니다.
36
+ const line = doc.lineAt(node.from);
37
+ out.push(headingLine[level].range(line.from));
38
+ const marks = node.getChildren("HeaderMark");
39
+ const open = marks[0];
40
+ if (!open)
41
+ return;
42
+ const markDeco = revealed ? atxFormatting[level] : hiddenMark;
43
+ // 여는 기호 + 뒤따르는 공백. 줄 끝을 넘지 않습니다.
44
+ // (빈 제목 "#" 다음에 줄바꿈이 오는 경우에도 줄바꿈을 숨기지 않도록)
45
+ let openEnd = open.to;
46
+ while (openEnd < line.to && isBlank(line.text.charCodeAt(openEnd - line.from)))
47
+ openEnd++;
48
+ out.push(markDeco.range(open.from, openEnd));
49
+ // 닫는 기호(`# 제목 ##`) + 그 앞의 공백
50
+ const close = marks.length > 1 ? marks[marks.length - 1] : undefined;
51
+ if (close && close.from >= openEnd) {
52
+ let closeStart = close.from;
53
+ while (closeStart > openEnd && isBlank(line.text.charCodeAt(closeStart - 1 - line.from))) {
54
+ closeStart--;
55
+ }
56
+ out.push(markDeco.range(closeStart, close.to));
57
+ }
58
+ }
59
+ /** `제목\n===` 형태 */
60
+ function decorateSetext(out, doc, node, level, revealed) {
61
+ const underline = node.lastChild;
62
+ if (!underline || underline.name !== "HeaderMark")
63
+ return;
64
+ // 줄 번호로 순회하므로 들여쓰기/인용 안의 밑줄에서도 무한 루프가 생기지 않습니다.
65
+ const firstLine = doc.lineAt(node.from).number;
66
+ const underlineLine = doc.lineAt(underline.from);
67
+ for (let n = firstLine; n < underlineLine.number; n++) {
68
+ out.push(headingLine[level].range(doc.line(n).from));
69
+ }
70
+ // 밑줄 줄 전체를 숨기려면 줄바꿈까지 대체해야 하는데, ViewPlugin에서는 불가능하므로
71
+ // 클래스만 바꿔 테마에서 표현하도록 합니다.
72
+ out.push((revealed ? setextUnderlineRevealed : setextUnderline)[level].range(underlineLine.from));
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // 플러그인
76
+ // ---------------------------------------------------------------------------
77
+ export const headingPlugin = createRevealPlugin({
78
+ // 줄 데코레이션과 기호 데코레이션이 같은 위치에서 섞일 수 있으므로 정렬합니다.
79
+ sort: true,
80
+ visit({ state, decorations, isRevealed }) {
81
+ const { doc } = state;
82
+ return {
83
+ enter(node) {
84
+ if (CONTAINERS.has(node.name))
85
+ return; // 자식 블록 순회
86
+ const spec = HEADINGS.get(node.name);
87
+ if (spec) {
88
+ (spec.setext ? decorateSetext : decorateAtx)(decorations, doc, node.node, spec.level, isRevealed(node.from, node.to));
89
+ }
90
+ // 제목 내부(인라인)와 그 밖의 블록은 순회하지 않습니다.
91
+ return false;
92
+ },
93
+ };
94
+ },
95
+ });
96
+ // ---------------------------------------------------------------------------
97
+ // 테마
98
+ // ---------------------------------------------------------------------------
99
+ const headingTheme = EditorView.baseTheme({
100
+ ".cm-heading": {
101
+ fontWeight: "bold",
102
+ },
103
+ // CodeMirror는 줄 높이를 getBoundingClientRect().height로 측정하므로 margin은
104
+ // 높이 계산에서 빠집니다(클릭/스크롤 위치 어긋남). 세로 여백은 padding으로 줍니다.
105
+ // .cm-line의 기본 가로 padding을 덮어쓰지 않도록 top/bottom만 지정합니다.
106
+ ".cm-line.cm-heading": {
107
+ paddingTop: "0.5rem",
108
+ paddingBottom: "0.35rem",
109
+ },
110
+ ".cm-heading *": {
111
+ textDecoration: "none",
112
+ },
113
+ // '#'(HeaderMark)에는 구문 강조(processingInstruction)가 안쪽 span에 직접 색을 지정하므로,
114
+ // 기호 영역의 안쪽 span이 제목 색(.cm-heading-N)을 상속하도록 합니다.
115
+ // .cm-line을 붙여 테마 범위가 지정된 HighlightStyle(명시도 0,2,0)보다 우선하게 합니다.
116
+ ".cm-line .cm-formatting-heading *": {
117
+ color: "inherit",
118
+ },
119
+ ".cm-heading-line *": {
120
+ textDecoration: "none",
121
+ },
122
+ ".cm-heading-1": {
123
+ fontSize: "2.027rem",
124
+ lineHeight: "1.15",
125
+ color: "oklch(0.577 0.245 27.325)", // dark and light common
126
+ },
127
+ ".cm-heading-2": {
128
+ fontSize: "1.802rem",
129
+ lineHeight: "1.15",
130
+ color: "oklch(0.769 0.188 70.08)", // dark and light common
131
+ },
132
+ ".cm-heading-3": {
133
+ fontSize: "1.602rem",
134
+ lineHeight: "1.15",
135
+ color: "oklch(0.905 0.182 98.111)", // dark and light common
136
+ },
137
+ ".cm-heading-4": {
138
+ fontSize: "1.424rem",
139
+ lineHeight: "1.15",
140
+ color: "oklch(0.841 0.238 128.85)", // dark and light common
141
+ },
142
+ ".cm-heading-5": {
143
+ fontSize: "1.266rem",
144
+ lineHeight: "1.15",
145
+ color: "oklch(0.746 0.16 232.661)", // dark and light common
146
+ },
147
+ ".cm-heading-6": {
148
+ fontSize: "1.125rem",
149
+ lineHeight: "1.15",
150
+ color: "oklch(0.714 0.203 305.504)", // dark and light common
151
+ },
152
+ });
153
+ /**
154
+ * 제목(ATX `#`, Setext `===`/`---`)에 스타일을 적용하고,
155
+ * `#` 기호는 커서가 해당 제목에 닿아 있을 때만 보여주는 확장입니다.
156
+ */
157
+ export function heading() {
158
+ return [headingPlugin, headingTheme];
159
+ }
@@ -0,0 +1,5 @@
1
+ import { Tag } from "@lezer/highlight";
2
+ /** `==text==` 하이라이트(mark) 문법에 사용할 하이라이트 태그 */
3
+ export declare const markTag: Tag;
4
+ /** `==text==` 인라인 하이라이트 문법 (노드: Highlight, HighlightMark) */
5
+ export declare const Highlight: import("@lezer/markdown").MarkdownConfig;
@@ -0,0 +1,6 @@
1
+ import { Tag, tags } from "@lezer/highlight";
2
+ import { doubleDelimiterSyntax } from "./delimiter.js";
3
+ /** `==text==` 하이라이트(mark) 문법에 사용할 하이라이트 태그 */
4
+ export const markTag = Tag.define(tags.content);
5
+ /** `==text==` 인라인 하이라이트 문법 (노드: Highlight, HighlightMark) */
6
+ export const Highlight = doubleDelimiterSyntax({ name: "Highlight", char: "=", tag: markTag });
@@ -0,0 +1,21 @@
1
+ import type { Extension } from "@codemirror/state";
2
+ interface RuleInfo {
3
+ readonly from: number;
4
+ readonly to: number;
5
+ readonly lineFrom: number;
6
+ readonly lineTo: number;
7
+ /** 줄에 구분선만 있는지 (앞뒤 공백 제외). 아니면 인용 등 접두어가 있는 줄 */
8
+ readonly wholeLine: boolean;
9
+ }
10
+ /**
11
+ * 구분선 `---`, `***`, `___`를 보여주는 StateField.
12
+ * (줄 전체를 대체하는 블록 위젯은 ViewPlugin으로 제공할 수 없습니다)
13
+ */
14
+ export declare const horizontalRuleField: import("@codemirror/state").StateField<import("./extensions.js").RevealFieldValue<RuleInfo>>;
15
+ /**
16
+ * 구분선(`---`, `***`, `___`)을 하이브리드 방식으로 보여주는 확장입니다.
17
+ * - 커서가 닿지 않으면 구분선을 그리고, 닿으면 원문을 보여줍니다.
18
+ * - 인용 안처럼 접두어가 있는 줄은 줄을 쪼개지 않도록 접두어 뒤에 인라인으로 그립니다.
19
+ */
20
+ export declare function horizontalRule(): Extension;
21
+ export {};
@@ -0,0 +1,142 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import { Decoration, EditorView, WidgetType } from "@codemirror/view";
3
+ import { createRevealField } from "./utils/reveal.js";
4
+ // ---------------------------------------------------------------------------
5
+ // 위젯
6
+ // ---------------------------------------------------------------------------
7
+ class HorizontalRuleWidget extends WidgetType {
8
+ block;
9
+ constructor(block) {
10
+ super();
11
+ this.block = block;
12
+ }
13
+ eq(other) {
14
+ return other.block === this.block;
15
+ }
16
+ toDOM() {
17
+ if (!this.block) {
18
+ // 인용 등 접두어가 있는 줄: 접두어 뒤를 채우는 인라인 구분선
19
+ const span = document.createElement("span");
20
+ span.className = "cm-hr cm-hr-inline";
21
+ return span;
22
+ }
23
+ // 블록 위젯에 세로 margin을 주면 에디터의 높이 계산이 어긋나므로,
24
+ // 감싸는 요소의 padding으로 여백을 주고 hr의 margin은 0으로 둡니다.
25
+ const wrap = document.createElement("div");
26
+ wrap.className = "cm-hr cm-hr-block";
27
+ wrap.appendChild(document.createElement("hr")).className = "cm-hr-line";
28
+ return wrap;
29
+ }
30
+ // 클릭을 에디터가 처리해 커서가 구분선으로 이동하고 원문이 펼쳐지도록 합니다.
31
+ ignoreEvent() {
32
+ return false;
33
+ }
34
+ }
35
+ const blockRule = Decoration.replace({ widget: new HorizontalRuleWidget(true), block: true });
36
+ const inlineRule = Decoration.replace({ widget: new HorizontalRuleWidget(false) });
37
+ const inlineRuleLine = Decoration.line({ class: "cm-hr-inline-line" });
38
+ const revealedLine = Decoration.line({ class: "cm-formatting cm-formatting-hr-line cm-hr-line" });
39
+ /** 구분선을 담을 수 있는 컨테이너 블록 */
40
+ const CONTAINERS = new Set([
41
+ "Blockquote",
42
+ "BulletList",
43
+ "OrderedList",
44
+ "ListItem",
45
+ ]);
46
+ /**
47
+ * 구분선은 블록이므로, 문서 전체 트리를 훑지 않고 블록 컨테이너만 내려가며 찾습니다.
48
+ * (문단 안의 인라인 요소는 방문하지 않습니다)
49
+ */
50
+ function scanRules(state) {
51
+ const { doc } = state;
52
+ const rules = [];
53
+ const cursor = syntaxTree(state).cursor();
54
+ const visitChildren = () => {
55
+ if (!cursor.firstChild())
56
+ return;
57
+ do {
58
+ if (cursor.type.is("HorizontalRule")) {
59
+ const line = doc.lineAt(cursor.from);
60
+ const before = line.text.slice(0, cursor.from - line.from);
61
+ const after = line.text.slice(Math.min(cursor.to, line.to) - line.from);
62
+ rules.push({
63
+ from: cursor.from,
64
+ to: cursor.to,
65
+ lineFrom: line.from,
66
+ lineTo: line.to,
67
+ wholeLine: !before.trim() && !after.trim(),
68
+ });
69
+ }
70
+ else if (CONTAINERS.has(cursor.name)) {
71
+ visitChildren();
72
+ }
73
+ } while (cursor.nextSibling());
74
+ cursor.parent();
75
+ };
76
+ visitChildren();
77
+ return rules;
78
+ }
79
+ /**
80
+ * 구분선 `---`, `***`, `___`를 보여주는 StateField.
81
+ * (줄 전체를 대체하는 블록 위젯은 ViewPlugin으로 제공할 수 없습니다)
82
+ */
83
+ export const horizontalRuleField = createRevealField({
84
+ scan: scanRules,
85
+ block: (rule) => rule.wholeLine,
86
+ build(rules, revealed) {
87
+ const out = [];
88
+ rules.forEach((rule, i) => {
89
+ if (revealed[i]) {
90
+ // 줄 데코레이션은 줄 시작에 있어야 합니다. (인용 안에서는 rule.from이 줄 시작이 아님)
91
+ out.push(revealedLine.range(rule.lineFrom));
92
+ }
93
+ else if (rule.wholeLine) {
94
+ out.push(blockRule.range(rule.lineFrom, rule.lineTo));
95
+ }
96
+ else {
97
+ // 줄 중간의 블록 위젯은 줄을 둘로 쪼개므로, 접두어가 있는 줄은 인라인으로 그립니다.
98
+ out.push(inlineRuleLine.range(rule.lineFrom));
99
+ out.push(inlineRule.range(rule.from, rule.to));
100
+ }
101
+ });
102
+ // 구분선이 문서 순서이고, 줄 데코레이션(줄 시작) → 대체(그 뒤) 순서로 추가됩니다.
103
+ return Decoration.set(out);
104
+ },
105
+ });
106
+ // ---------------------------------------------------------------------------
107
+ // 테마
108
+ // ---------------------------------------------------------------------------
109
+ const horizontalRuleTheme = EditorView.baseTheme({
110
+ ".cm-hr-block": {
111
+ padding: "0.35rem 0.5rem",
112
+ },
113
+ ".cm-hr-block hr.cm-hr-line": {
114
+ margin: "0",
115
+ border: "none",
116
+ borderTop: "1px solid",
117
+ },
118
+ // 접두어 뒤의 남은 공간을 구분선이 채우도록 줄을 flex로 배치합니다.
119
+ ".cm-hr-inline-line": {
120
+ display: "flex",
121
+ alignItems: "center",
122
+ },
123
+ ".cm-hr-inline": {
124
+ flex: "1",
125
+ borderTop: "1px solid",
126
+ margin: "0 0.5rem",
127
+ },
128
+ "&light .cm-hr-line, &light .cm-hr-inline": {
129
+ borderColor: "oklch(0.869 0.005 56.366)",
130
+ },
131
+ "&dark .cm-hr-line, &dark .cm-hr-inline": {
132
+ borderColor: "oklch(0.444 0.011 73.639)",
133
+ },
134
+ });
135
+ /**
136
+ * 구분선(`---`, `***`, `___`)을 하이브리드 방식으로 보여주는 확장입니다.
137
+ * - 커서가 닿지 않으면 구분선을 그리고, 닿으면 원문을 보여줍니다.
138
+ * - 인용 안처럼 접두어가 있는 줄은 줄을 쪼개지 않도록 접두어 뒤에 인라인으로 그립니다.
139
+ */
140
+ export function horizontalRule() {
141
+ return [horizontalRuleField, horizontalRuleTheme];
142
+ }
@@ -0,0 +1,58 @@
1
+ import type { EditorState, Extension } from "@codemirror/state";
2
+ import type { SyntaxNode } from "@lezer/common";
3
+ export type ImageAlign = "left" | "center" | "right";
4
+ export interface ImageAltOptions {
5
+ /** 캡션 (대체 텍스트). 이스케이프(`\|` 등)가 해제된 값 */
6
+ readonly caption: string;
7
+ /** CSS 너비 (예: "300px", "50%") */
8
+ readonly width: string | null;
9
+ /** CSS 높이 (예: "200px") */
10
+ readonly height: string | null;
11
+ readonly align: ImageAlign | null;
12
+ /** 원문 대체 텍스트에서 캡션이 끝나는 위치 (그 뒤는 |size|align 옵션) */
13
+ readonly captionLength: number;
14
+ }
15
+ /**
16
+ * 이미지 대체 텍스트 `caption|size|align`을 해석합니다.
17
+ * - 뒤에서부터 크기(`300`, `300x200`, `x200`, `50%`)나 정렬(`left|center|right`) 형식인 조각만
18
+ * 옵션으로 인식하고, 나머지는 모두 캡션입니다. (캡션에 "|"가 있어도 깨지지 않음, `\|`로 이스케이프 가능)
19
+ * - inTable: 표 칸 안의 이미지. `\|`도 구분자로 봅니다. (`![아이콘\|16x16](url)`)
20
+ * - 크기와 정렬의 순서는 바뀌어도 되고 각각 생략할 수 있습니다.
21
+ */
22
+ export declare function parseImageAlt(raw: string, inTable?: boolean): ImageAltOptions;
23
+ export interface ImageInfo extends ImageAltOptions {
24
+ readonly from: number;
25
+ readonly to: number;
26
+ /** 대체 텍스트 범위 (![ 와 ] 사이) */
27
+ readonly altFrom: number;
28
+ readonly altTo: number;
29
+ readonly src: string;
30
+ readonly title: string | null;
31
+ /** 최상위 문단에서 이미지만 있는 줄이면 그 줄 범위 (블록 이미지로 표시), 아니면 null (인라인 이미지) */
32
+ readonly line: {
33
+ readonly from: number;
34
+ readonly to: number;
35
+ } | null;
36
+ /** 기호와 URL/제목 노드 (원문 표시용) */
37
+ readonly parts: {
38
+ readonly open: SyntaxNode;
39
+ readonly close: SyntaxNode;
40
+ readonly paren: SyntaxNode;
41
+ readonly end: SyntaxNode;
42
+ readonly url: SyntaxNode | null;
43
+ readonly title: SyntaxNode | null;
44
+ };
45
+ }
46
+ /** Image 노드에서 `![alt](url "title")` 정보를 읽습니다. 참조 이미지(`![a][ref]`)는 null */
47
+ export declare function readImage(state: EditorState, node: SyntaxNode): ImageInfo | null;
48
+ export interface ImageOptions {
49
+ /** 원문의 주소를 실제로 불러올 주소로 바꿉니다. (상대 경로 처리 등) 기본값: 그대로 사용 */
50
+ resolveUrl?: (src: string) => string;
51
+ }
52
+ /**
53
+ * 이미지 `![caption|size|align](url "title")`를 하이브리드 방식으로 보여주는 확장입니다.
54
+ * - 이미지만 있는 줄은 블록 이미지(캡션/정렬 포함)로, 글 사이의 이미지는 인라인 이미지로 보여줍니다.
55
+ * - 커서가 이미지에 닿으면 원문을 보여주고, 이미지만 있는 줄은 원문 아래에 미리보기를 유지합니다.
56
+ * - 대체 텍스트의 `|size|align` 옵션: 크기 `300`, `300x200`, `x200`, `50%` / 정렬 `left|center|right`
57
+ */
58
+ export declare function image(options?: ImageOptions): Extension;