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
package/dist/link.js ADDED
@@ -0,0 +1,217 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import { Prec } from "@codemirror/state";
3
+ import { Decoration, EditorView } from "@codemirror/view";
4
+ import { createRevealPlugin } from "./utils/reveal.js";
5
+ const unescape = (text) => text.replace(/\\([!-/:-@[-`{-~])/g, "$1");
6
+ /**
7
+ * Link 노드에서 인라인 링크 `[label](url "title")` 정보를 읽습니다.
8
+ * 참조 링크(`[a][ref]`, `[a]`)처럼 인라인 링크가 아니면 null을 반환합니다.
9
+ */
10
+ export function readInlineLink(state, link) {
11
+ if (link.name !== "Link")
12
+ return null;
13
+ // 자식을 한 번만 훑어 기호/URL/제목을 찾습니다. (getChildren/getChild 반복 호출로 인한 배열 할당 방지)
14
+ const marks = [];
15
+ let url = null;
16
+ let titleNode = null;
17
+ for (let child = link.firstChild; child; child = child.nextSibling) {
18
+ if (child.name === "LinkMark")
19
+ marks.push(child);
20
+ else if (child.name === "URL")
21
+ url = child;
22
+ else if (child.name === "LinkTitle")
23
+ titleNode = child;
24
+ }
25
+ // 인라인 링크는 기호가 정확히 [ ] ( ) 4개입니다. (참조 링크 [a][ref], [a]는 2개)
26
+ if (marks.length !== 4)
27
+ return null;
28
+ const [open, close, paren, end] = marks;
29
+ const { doc } = state;
30
+ const charAt = (pos) => doc.sliceString(pos, pos + 1);
31
+ if (open.to - open.from !== 1 ||
32
+ charAt(open.from) !== "[" ||
33
+ charAt(close.from) !== "]" ||
34
+ charAt(paren.from) !== "(" ||
35
+ charAt(end.from) !== ")") {
36
+ return null;
37
+ }
38
+ let href = url ? doc.sliceString(url.from, url.to) : "";
39
+ if (href.startsWith("<") && href.endsWith(">"))
40
+ href = href.slice(1, -1);
41
+ const rawTitle = titleNode ? doc.sliceString(titleNode.from, titleNode.to) : null;
42
+ return {
43
+ from: link.from,
44
+ to: link.to,
45
+ labelFrom: open.to,
46
+ labelTo: close.from,
47
+ urlFrom: url?.from ?? null,
48
+ urlTo: url?.to ?? null,
49
+ titleFrom: titleNode?.from ?? null,
50
+ titleTo: titleNode?.to ?? null,
51
+ href: href.includes("\\") ? unescape(href) : href,
52
+ title: rawTitle === null ? null : unescape(rawTitle.slice(1, -1)),
53
+ marks,
54
+ };
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // 데코레이션
58
+ // ---------------------------------------------------------------------------
59
+ const hidden = Decoration.replace({});
60
+ const linkMarkDeco = Decoration.mark({ class: "cm-formatting cm-formatting-link cm-link" });
61
+ const urlMarkDeco = Decoration.mark({ class: "cm-formatting cm-formatting-url cm-url" });
62
+ const labelDeco = Decoration.mark({ class: "cm-link" });
63
+ const urlDeco = Decoration.mark({ class: "cm-url" });
64
+ const titleDeco = Decoration.mark({ class: "cm-url cm-link-title" });
65
+ /**
66
+ * 렌더링된 링크 라벨. 툴팁(title 속성)이 링크마다 달라서 툴팁별로 만들어 재사용합니다.
67
+ * (캐시가 무한히 커지지 않도록 일정 개수를 넘으면 비웁니다)
68
+ */
69
+ const labelCache = new Map();
70
+ function renderedLabel(tooltip) {
71
+ let deco = labelCache.get(tooltip);
72
+ if (!deco) {
73
+ if (labelCache.size >= 1000)
74
+ labelCache.clear();
75
+ // inclusive: 라벨 전체가 다른 데코레이션(예: 이미지 위젯)으로 대체되어도 그 위젯을 감싸도록 합니다.
76
+ deco = Decoration.mark({
77
+ class: "cm-link cm-link-rendered",
78
+ attributes: { title: tooltip },
79
+ inclusive: true,
80
+ });
81
+ labelCache.set(tooltip, deco);
82
+ }
83
+ return deco;
84
+ }
85
+ /** 링크가 올 수 없으므로 자식 순회를 생략하는 노드 */
86
+ const SKIP_CHILDREN = new Set([
87
+ "FencedCode",
88
+ "CodeBlock",
89
+ "InlineCode",
90
+ "HTMLBlock",
91
+ "CommentBlock",
92
+ "Image",
93
+ "Autolink",
94
+ "URL",
95
+ ]);
96
+ export const linkPlugin = createRevealPlugin({
97
+ // 링크 안에서 [ → 라벨 → ] → ( → URL → 제목 → ) 순서로 추가되므로 정렬이 필요 없습니다.
98
+ visit({ state, decorations, isRevealed }) {
99
+ return {
100
+ enter(node) {
101
+ if (node.name === "Link") {
102
+ const link = readInlineLink(state, node.node);
103
+ if (!link)
104
+ return false; // 참조 링크 등은 원문 그대로
105
+ const [open, close, paren, end] = link.marks;
106
+ const hasLabel = link.labelFrom < link.labelTo;
107
+ if (isRevealed(link.from, link.to)) {
108
+ // 원문을 보여주되 부분별로 스타일을 적용합니다.
109
+ decorations.push(linkMarkDeco.range(open.from, open.to));
110
+ if (hasLabel)
111
+ decorations.push(labelDeco.range(link.labelFrom, link.labelTo));
112
+ decorations.push(linkMarkDeco.range(close.from, close.to));
113
+ decorations.push(urlMarkDeco.range(paren.from, paren.to));
114
+ if (link.urlFrom !== null && link.urlFrom < link.urlTo) {
115
+ decorations.push(urlDeco.range(link.urlFrom, link.urlTo));
116
+ }
117
+ if (link.titleFrom !== null) {
118
+ decorations.push(titleDeco.range(link.titleFrom, link.titleTo));
119
+ }
120
+ decorations.push(urlMarkDeco.range(end.from, end.to));
121
+ return false;
122
+ }
123
+ const tooltip = link.title ?? link.href;
124
+ if (hasLabel) {
125
+ // [ 숨김 → 라벨(링크 스타일, 안쪽 인라인 문법 유지) → ](url "title") 숨김
126
+ decorations.push(hidden.range(link.from, link.labelFrom));
127
+ decorations.push(renderedLabel(tooltip).range(link.labelFrom, link.labelTo));
128
+ decorations.push(hidden.range(link.labelTo, link.to));
129
+ }
130
+ else if (link.urlFrom !== null && link.urlFrom < link.urlTo) {
131
+ // 라벨이 비어 있으면(`[](url)`) 링크가 사라지지 않도록 URL을 라벨처럼 보여줍니다.
132
+ decorations.push(hidden.range(link.from, link.urlFrom));
133
+ decorations.push(renderedLabel(tooltip).range(link.urlFrom, link.urlTo));
134
+ decorations.push(hidden.range(link.urlTo, link.to));
135
+ }
136
+ // 라벨과 URL이 모두 비어 있으면(`[]()`) 원문 그대로 둡니다.
137
+ return false;
138
+ }
139
+ if (SKIP_CHILDREN.has(node.name))
140
+ return false;
141
+ },
142
+ };
143
+ },
144
+ });
145
+ // ---------------------------------------------------------------------------
146
+ // 클릭으로 열기
147
+ // ---------------------------------------------------------------------------
148
+ /** 기본으로 열어 주는 주소 형식 (javascript:, data: 등은 열지 않습니다) */
149
+ const SAFE_URL = /^(?:https?:|mailto:|tel:|ftp:)/i;
150
+ /** 기본으로 열어도 안전한 주소인지 (http(s), mailto, tel, ftp) */
151
+ export function isSafeUrl(href) {
152
+ return SAFE_URL.test(href);
153
+ }
154
+ /** 기본 열기 동작: 안전한 절대 주소만 새 탭으로 엽니다. */
155
+ export function openLinkSafely(href) {
156
+ if (isSafeUrl(href))
157
+ window.open(href, "_blank", "noopener,noreferrer");
158
+ }
159
+ /** 위치에 있는 인라인 링크 */
160
+ function linkAt(state, pos) {
161
+ for (let n = syntaxTree(state).resolveInner(pos, 1); n; n = n.parent) {
162
+ if (n.name === "Link")
163
+ return readInlineLink(state, n);
164
+ }
165
+ return null;
166
+ }
167
+ function linkClickHandler(openLink) {
168
+ return EditorView.domEventHandlers({
169
+ mousedown(event, view) {
170
+ if (event.button !== 0)
171
+ return false;
172
+ const target = event.target?.closest?.(".cm-link-rendered");
173
+ if (!target || !view.contentDOM.contains(target))
174
+ return false;
175
+ const link = linkAt(view.state, view.posAtDOM(target));
176
+ if (!link || !link.href)
177
+ return false;
178
+ // 커서를 옮기지 않고(원문이 펼쳐지지 않고) 링크만 엽니다.
179
+ event.preventDefault();
180
+ openLink(link.href, event);
181
+ return true;
182
+ },
183
+ });
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // 테마
187
+ // ---------------------------------------------------------------------------
188
+ const linkTheme = EditorView.baseTheme({
189
+ ".cm-link-rendered": {
190
+ padding: "0.15rem 0.25rem",
191
+ borderRadius: "0.25rem",
192
+ transition: "100ms",
193
+ cursor: "pointer",
194
+ },
195
+ // 어두운 테마에서는 밝은 배경 위의 밝은 글자가 보이지 않으므로 배경을 어둡게 합니다.
196
+ "&light .cm-link-rendered": { backgroundColor: "oklch(92.2% 0 0)" },
197
+ "&light .cm-link-rendered:hover": { backgroundColor: "oklch(87% 0 0)" },
198
+ "&dark .cm-link-rendered": { backgroundColor: "oklch(34% 0 0)" },
199
+ "&dark .cm-link-rendered:hover": { backgroundColor: "oklch(40% 0 0)" },
200
+ });
201
+ /**
202
+ * 인라인 링크 `[label](url "title")`를 하이브리드 방식으로 보여주는 확장입니다.
203
+ * - 커서가 링크에 닿지 않으면 기호와 URL을 숨기고 라벨만 링크 스타일로 보여줍니다.
204
+ * (라벨 안의 굵게/코드 등 인라인 문법은 그대로 유지)
205
+ * - 렌더링된 라벨을 클릭하면 링크를 엽니다. (기본: http(s)/mailto/tel/ftp만 새 탭으로)
206
+ * - 참조 링크(`[a][ref]`, `[a]`), 자동 링크, 이미지는 다루지 않습니다.
207
+ */
208
+ export function link(options = {}) {
209
+ return [
210
+ // 겹치는 mark 데코레이션은 우선순위가 낮은(뒤에 오는) 소스가 바깥쪽에 그려집니다.
211
+ // 라벨이 굵게/코드 등 다른 플러그인의 span 바깥을 하나의 span으로 감싸도록 우선순위를 낮춥니다.
212
+ // (그렇지 않으면 라벨이 여러 조각으로 나뉘어 배경/모서리 스타일이 끊어집니다)
213
+ Prec.low(linkPlugin),
214
+ linkClickHandler(options.openLink ?? openLinkSafely),
215
+ linkTheme,
216
+ ];
217
+ }
package/dist/list.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { Facet, type Extension } from "@codemirror/state";
2
+ /**
3
+ * 불릿 작업 목록 항목(`- [ ] 할 일`)의 기호를 다른 확장(task())이 담당하는지 여부.
4
+ * true이면 list()는 해당 항목의 불릿을 그리지 않습니다. (같은 범위를 두 플러그인이 대체하지 않도록)
5
+ */
6
+ export declare const taskMarkersHandled: Facet<boolean, boolean>;
7
+ export declare const listPlugin: import("@codemirror/view").ViewPlugin<import("./extensions.js").RevealPluginValue, undefined>;
8
+ /**
9
+ * 불릿/번호 목록을 하이브리드 방식으로 보여주는 확장입니다.
10
+ * - 불릿 기호(-, *, +)는 깊이별 불릿(•, ◦, ▪)으로 보여주고, 커서가 기호에 닿으면 원래 기호를 보여줍니다.
11
+ * - 번호 기호(1., 1))는 항상 보여주고 스타일만 적용합니다.
12
+ * - 줄과 기호에 깊이별 클래스(cm-list-line-N, cm-list-N)를 붙입니다. (깊이 제한 없음)
13
+ */
14
+ export declare function list(): Extension;
package/dist/list.js ADDED
@@ -0,0 +1,136 @@
1
+ import { Facet } from "@codemirror/state";
2
+ import { Decoration, EditorView, WidgetType } from "@codemirror/view";
3
+ import { createRevealPlugin } from "./utils/reveal.js";
4
+ /**
5
+ * 불릿 작업 목록 항목(`- [ ] 할 일`)의 기호를 다른 확장(task())이 담당하는지 여부.
6
+ * true이면 list()는 해당 항목의 불릿을 그리지 않습니다. (같은 범위를 두 플러그인이 대체하지 않도록)
7
+ */
8
+ export const taskMarkersHandled = Facet.define({
9
+ combine: (values) => values.some(Boolean),
10
+ });
11
+ // ---------------------------------------------------------------------------
12
+ // 불릿 위젯
13
+ // ---------------------------------------------------------------------------
14
+ /** 깊이별 불릿 모양 (깊이 4부터 다시 반복) */
15
+ const BULLETS = ["•", "◦", "▪"];
16
+ /** 깊이(1부터) → 불릿 모양 단계(1~3) */
17
+ const bulletLevel = (depth) => ((depth - 1) % BULLETS.length) + 1;
18
+ class BulletWidget extends WidgetType {
19
+ level;
20
+ constructor(level) {
21
+ super();
22
+ this.level = level;
23
+ }
24
+ eq(other) {
25
+ return other.level === this.level;
26
+ }
27
+ toDOM() {
28
+ const span = document.createElement("span");
29
+ span.className = `cm-list-bullet cm-list-bullet-${this.level}`;
30
+ span.textContent = BULLETS[this.level - 1];
31
+ return span;
32
+ }
33
+ // 불릿을 클릭해도 에디터가 커서를 옮길 수 있도록 이벤트를 에디터에 넘깁니다.
34
+ ignoreEvent() {
35
+ return false;
36
+ }
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // 데코레이션 (깊이 제한이 없으므로 깊이별로 처음 쓸 때 만들어 재사용합니다)
40
+ // ---------------------------------------------------------------------------
41
+ function cached(create) {
42
+ const cache = new Map();
43
+ return (depth) => {
44
+ let value = cache.get(depth);
45
+ if (value === undefined)
46
+ cache.set(depth, (value = create(depth)));
47
+ return value;
48
+ };
49
+ }
50
+ const listLine = cached((depth) => Decoration.line({ class: `cm-list-line cm-list-line-${depth}` }));
51
+ const bullet = [1, 2, 3].map((level) => Decoration.replace({ widget: new BulletWidget(level) }));
52
+ const bulletMark = cached((depth) => Decoration.mark({
53
+ class: `cm-formatting cm-formatting-list cm-formatting-list-ul cm-list-${depth}`,
54
+ }));
55
+ const orderedMark = cached((depth) => Decoration.mark({
56
+ class: `cm-formatting cm-formatting-list cm-formatting-list-ol cm-list-${depth}`,
57
+ }));
58
+ /** 목록 항목을 담을 수 있는 컨테이너 블록 (그 외 노드는 자식 순회 생략) */
59
+ const CONTAINERS = new Set([
60
+ "Document",
61
+ "Blockquote",
62
+ "BulletList",
63
+ "OrderedList",
64
+ "ListItem",
65
+ ]);
66
+ // ---------------------------------------------------------------------------
67
+ // 플러그인
68
+ // ---------------------------------------------------------------------------
69
+ export const listPlugin = createRevealPlugin({
70
+ // 한 줄에 항목이 여러 개("- - a")면 줄 데코레이션이 기호 뒤에 추가되므로 정렬합니다.
71
+ sort: true,
72
+ visit({ state, decorations, isRevealed }) {
73
+ const { doc } = state;
74
+ const skipTaskBullets = state.facet(taskMarkersHandled);
75
+ let depth = 0;
76
+ return {
77
+ enter(node) {
78
+ if (node.name === "ListItem") {
79
+ depth++;
80
+ return;
81
+ }
82
+ if (node.name === "ListMark") {
83
+ const item = node.node.parent;
84
+ const ordered = item?.parent?.name === "OrderedList";
85
+ decorations.push(listLine(depth).range(doc.lineAt(node.from).from));
86
+ if (!ordered && skipTaskBullets && item?.getChild("Task")) {
87
+ // 불릿 작업 목록의 기호는 task()가 체크박스로 그립니다.
88
+ }
89
+ else if (ordered) {
90
+ // 번호는 내용이므로 항상 보여주고 스타일만 적용합니다.
91
+ decorations.push(orderedMark(depth).range(node.from, node.to));
92
+ }
93
+ else if (isRevealed(node.from, node.to)) {
94
+ // 커서가 기호에 닿으면 원래 기호(-, *, +)를 보여줍니다.
95
+ decorations.push(bulletMark(depth).range(node.from, node.to));
96
+ }
97
+ else {
98
+ decorations.push(bullet[bulletLevel(depth) - 1].range(node.from, node.to));
99
+ }
100
+ return false;
101
+ }
102
+ if (!CONTAINERS.has(node.name))
103
+ return false;
104
+ },
105
+ leave(node) {
106
+ if (node.name === "ListItem")
107
+ depth--;
108
+ },
109
+ };
110
+ },
111
+ });
112
+ // ---------------------------------------------------------------------------
113
+ // 테마
114
+ // ---------------------------------------------------------------------------
115
+ const listTheme = EditorView.baseTheme({
116
+ "&light .cm-list-bullet, &light .cm-formatting-list": {
117
+ color: "#6b7280",
118
+ },
119
+ "&dark .cm-list-bullet, &dark .cm-formatting-list": {
120
+ color: "#9ca3af",
121
+ },
122
+ // 기호(ListMark)에는 구문 강조(processingInstruction)가 안쪽 span에 색을 직접 지정하므로,
123
+ // 안쪽 span이 목록 기호 색을 상속하도록 합니다. (제목의 '#'과 같은 처리)
124
+ ".cm-line .cm-formatting-list *": {
125
+ color: "inherit",
126
+ },
127
+ });
128
+ /**
129
+ * 불릿/번호 목록을 하이브리드 방식으로 보여주는 확장입니다.
130
+ * - 불릿 기호(-, *, +)는 깊이별 불릿(•, ◦, ▪)으로 보여주고, 커서가 기호에 닿으면 원래 기호를 보여줍니다.
131
+ * - 번호 기호(1., 1))는 항상 보여주고 스타일만 적용합니다.
132
+ * - 줄과 기호에 깊이별 클래스(cm-list-line-N, cm-list-N)를 붙입니다. (깊이 제한 없음)
133
+ */
134
+ export function list() {
135
+ return [listPlugin, listTheme];
136
+ }
@@ -0,0 +1,20 @@
1
+ import type { StateCommand } from "@codemirror/state";
2
+ /**
3
+ * 가장 바깥 목록의 마지막 빈 항목에서 Enter를 누르면, 목록을 끝내고 빈 줄을 넣은 뒤
4
+ * 새 문단에서 이어 쓰도록 합니다.
5
+ *
6
+ * ```
7
+ * - a - a
8
+ * - | →
9
+ * |
10
+ * ```
11
+ *
12
+ * 빈 줄이 없으면 마크다운 규칙(lazy continuation)상 다음에 쓰는 줄이 바로 위 항목의
13
+ * 이어지는 줄이 되어, 그 줄에서 Enter를 누를 때 항목 들여쓰기가 들어가는 등 목록에 섞입니다.
14
+ *
15
+ * 다음 경우에는 false를 반환해 기본 Enter 동작(lang-markdown)에 맡깁니다.
16
+ * - 중첩된 목록의 빈 항목 (한 단계 내어쓰기)
17
+ * - 목록 중간의 빈 항목 (빈 줄을 넣으면 뒤 항목들이 새 문단의 이어지는 줄이 됨)
18
+ * - 바로 위 줄이 이미 빈 줄인 경우, 여러 커서, 선택 영역이 있는 경우
19
+ */
20
+ export declare const endListWithBlankLine: StateCommand;
@@ -0,0 +1,72 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ /** 목록 기호 뒤에 내용이 없는지 (공백, 작업 목록 표시 `[ ]`만 있는 경우 포함) */
3
+ const EMPTY_ITEM_REST = /^\s*(?:\[[ xX]\]\s*)?$/;
4
+ /**
5
+ * 가장 바깥 목록의 마지막 빈 항목에서 Enter를 누르면, 목록을 끝내고 빈 줄을 넣은 뒤
6
+ * 새 문단에서 이어 쓰도록 합니다.
7
+ *
8
+ * ```
9
+ * - a - a
10
+ * - | →
11
+ * |
12
+ * ```
13
+ *
14
+ * 빈 줄이 없으면 마크다운 규칙(lazy continuation)상 다음에 쓰는 줄이 바로 위 항목의
15
+ * 이어지는 줄이 되어, 그 줄에서 Enter를 누를 때 항목 들여쓰기가 들어가는 등 목록에 섞입니다.
16
+ *
17
+ * 다음 경우에는 false를 반환해 기본 Enter 동작(lang-markdown)에 맡깁니다.
18
+ * - 중첩된 목록의 빈 항목 (한 단계 내어쓰기)
19
+ * - 목록 중간의 빈 항목 (빈 줄을 넣으면 뒤 항목들이 새 문단의 이어지는 줄이 됨)
20
+ * - 바로 위 줄이 이미 빈 줄인 경우, 여러 커서, 선택 영역이 있는 경우
21
+ */
22
+ export const endListWithBlankLine = ({ state, dispatch }) => {
23
+ const { selection, doc } = state;
24
+ if (selection.ranges.length !== 1 || !selection.main.empty)
25
+ return false;
26
+ const head = selection.main.head;
27
+ const line = doc.lineAt(head);
28
+ if (line.text.slice(head - line.from).trim())
29
+ return false; // 커서 뒤에 내용이 있음
30
+ // 이 줄에 기호가 있는 가장 안쪽 목록 항목
31
+ let mark = null;
32
+ syntaxTree(state).iterate({
33
+ from: line.from,
34
+ to: line.to,
35
+ enter(node) {
36
+ if (node.name === "ListMark" && node.from >= line.from)
37
+ mark = node.node;
38
+ },
39
+ });
40
+ const listMark = mark;
41
+ const item = listMark?.parent;
42
+ const list = item?.parent;
43
+ if (!listMark || !item || item.name !== "ListItem" || !list)
44
+ return false;
45
+ // 빈 항목이어야 합니다.
46
+ if (head < listMark.to || !EMPTY_ITEM_REST.test(line.text.slice(listMark.to - line.from)))
47
+ return false;
48
+ // 가장 바깥 목록이어야 합니다. (중첩 목록은 기본 동작으로 한 단계 내어쓰기)
49
+ if (list.parent?.name === "ListItem")
50
+ return false;
51
+ // 마지막 항목이어야 합니다.
52
+ for (let next = item.nextSibling; next; next = next.nextSibling) {
53
+ if (next.name === "ListItem")
54
+ return false;
55
+ }
56
+ // 바로 위 줄이 이미 빈 줄(인용 기호만 있는 줄 포함)이면 빈 줄을 더 넣지 않습니다.
57
+ if (line.number > 1 && !/[^\s>]/.test(doc.line(line.number - 1).text))
58
+ return false;
59
+ // 인용 기호(>)는 유지하고, 목록 들여쓰기는 버립니다. ("> 2. " → 빈 줄 ">" + 새 줄 "> ")
60
+ const before = line.text.slice(0, listMark.from - line.from);
61
+ const lastQuote = before.lastIndexOf(">");
62
+ const prefix = lastQuote < 0 ? "" : `${before.slice(0, lastQuote + 1)} `;
63
+ const blank = prefix.trimEnd();
64
+ const insert = `${blank}${state.lineBreak}${prefix}`;
65
+ dispatch(state.update({
66
+ changes: { from: line.from, to: line.to, insert },
67
+ selection: { anchor: line.from + insert.length },
68
+ scrollIntoView: true,
69
+ userEvent: "input",
70
+ }));
71
+ return true;
72
+ };
@@ -0,0 +1,23 @@
1
+ import { type Extension, type StateCommand } from "@codemirror/state";
2
+ import { type KeyBinding } from "@codemirror/view";
3
+ /**
4
+ * 선택한 목록 항목을 바로 앞 항목의 하위 항목으로 들여씁니다. (하위 항목도 함께 이동)
5
+ * - 들여쓰는 폭은 앞 항목의 내용 시작 열에 맞춥니다. (`- ` 2칸, `1. ` 3칸, `10. ` 4칸)
6
+ * - 앞 항목이 없는 첫 항목은 들여쓸 수 없으므로 아무것도 하지 않습니다.
7
+ * - 목록 항목이 아닌 줄이면 false를 반환해 다른 Tab 동작에 넘깁니다.
8
+ */
9
+ export declare const indentListItem: StateCommand;
10
+ /**
11
+ * 선택한 목록 항목을 한 단계 내어씁니다. (부모 항목과 같은 열로, 하위 항목도 함께 이동)
12
+ * - 뒤따르던 형제 항목들은 내어쓴 항목의 하위 항목이 됩니다.
13
+ * - 최상위 항목은 아무것도 하지 않습니다.
14
+ * - 목록 항목이 아닌 줄이면 false를 반환해 다른 Shift-Tab 동작에 넘깁니다.
15
+ */
16
+ export declare const outdentListItem: StateCommand;
17
+ /** Tab: 목록 항목 들여쓰기, Shift-Tab: 내어쓰기 */
18
+ export declare const listKeymap: readonly KeyBinding[];
19
+ /**
20
+ * 목록 항목에서 Tab/Shift-Tab으로 단계를 조절하는 확장입니다. (무한 깊이, 번호 자동 갱신)
21
+ * 목록이 아닌 줄에서는 다른 Tab 바인딩(예: indentWithTab)으로 넘어가도록 우선순위를 높여 둡니다.
22
+ */
23
+ export declare function listIndent(): Extension;