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.
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/callout.d.ts +17 -0
- package/dist/callout.js +541 -0
- package/dist/calloutHeader.d.ts +47 -0
- package/dist/calloutHeader.js +75 -0
- package/dist/codeBlock.d.ts +78 -0
- package/dist/codeBlock.js +686 -0
- package/dist/codeTheme.d.ts +47 -0
- package/dist/codeTheme.js +394 -0
- package/dist/delimiter.d.ts +20 -0
- package/dist/delimiter.js +73 -0
- package/dist/editor.d.ts +114 -0
- package/dist/editor.js +199 -0
- package/dist/emphasis.d.ts +6 -0
- package/dist/emphasis.js +101 -0
- package/dist/escape.d.ts +4 -0
- package/dist/escape.js +39 -0
- package/dist/extensions.d.ts +66 -0
- package/dist/extensions.js +79 -0
- package/dist/footnote.d.ts +24 -0
- package/dist/footnote.js +456 -0
- package/dist/footnoteSyntax.d.ts +23 -0
- package/dist/footnoteSyntax.js +107 -0
- package/dist/hashtag.d.ts +4 -0
- package/dist/hashtag.js +64 -0
- package/dist/hashtagSyntax.d.ts +36 -0
- package/dist/hashtagSyntax.js +99 -0
- package/dist/heading.d.ts +7 -0
- package/dist/heading.js +159 -0
- package/dist/highlight.d.ts +5 -0
- package/dist/highlight.js +6 -0
- package/dist/horizontalRule.d.ts +21 -0
- package/dist/horizontalRule.js +142 -0
- package/dist/image.d.ts +58 -0
- package/dist/image.js +344 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/inlineCode.d.ts +4 -0
- package/dist/inlineCode.js +68 -0
- package/dist/link.d.ts +47 -0
- package/dist/link.js +217 -0
- package/dist/list.d.ts +14 -0
- package/dist/list.js +136 -0
- package/dist/listEnter.d.ts +20 -0
- package/dist/listEnter.js +72 -0
- package/dist/listIndent.d.ts +23 -0
- package/dist/listIndent.js +335 -0
- package/dist/markVision.d.ts +17 -0
- package/dist/markVision.js +56 -0
- package/dist/math.d.ts +45 -0
- package/dist/math.js +233 -0
- package/dist/mathSyntax.d.ts +24 -0
- package/dist/mathSyntax.js +179 -0
- package/dist/mention.d.ts +4 -0
- package/dist/mention.js +58 -0
- package/dist/mentionSyntax.d.ts +14 -0
- package/dist/mentionSyntax.js +71 -0
- package/dist/quote.d.ts +9 -0
- package/dist/quote.js +122 -0
- package/dist/setup.d.ts +13 -0
- package/dist/setup.js +40 -0
- package/dist/style.css +6 -0
- package/dist/style.css.d.ts +2 -0
- package/dist/table.d.ts +38 -0
- package/dist/table.js +960 -0
- package/dist/tableEdit.d.ts +30 -0
- package/dist/tableEdit.js +305 -0
- package/dist/tableMenu.d.ts +65 -0
- package/dist/tableMenu.js +1125 -0
- package/dist/tableModel.d.ts +168 -0
- package/dist/tableModel.js +433 -0
- package/dist/tableState.d.ts +36 -0
- package/dist/tableState.js +96 -0
- package/dist/task.d.ts +13 -0
- package/dist/task.js +179 -0
- package/dist/textMate.d.ts +49 -0
- package/dist/textMate.js +284 -0
- package/dist/tikz.d.ts +56 -0
- package/dist/tikz.js +343 -0
- package/dist/underline.d.ts +5 -0
- package/dist/underline.js +6 -0
- package/dist/utils/blockMargin.d.ts +6 -0
- package/dist/utils/blockMargin.js +6 -0
- package/dist/utils/codePoint.d.ts +13 -0
- package/dist/utils/codePoint.js +43 -0
- package/dist/utils/container.d.ts +10 -0
- package/dist/utils/container.js +10 -0
- package/dist/utils/reveal.d.ts +99 -0
- package/dist/utils/reveal.js +161 -0
- package/dist/utils/selection.d.ts +33 -0
- package/dist/utils/selection.js +45 -0
- package/dist/utils/syntaxTree.d.ts +18 -0
- package/dist/utils/syntaxTree.js +23 -0
- package/package.json +79 -4
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { syntaxTree } from "@codemirror/language";
|
|
2
|
+
import { StateEffect, StateField } from "@codemirror/state";
|
|
3
|
+
import { scanTables, tableRowAt } from "./tableModel.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// 문서 전체의 표 목록
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
/** 문서의 모든 표 (문서 순서). 문서/구문 트리/설정이 바뀔 때만 다시 찾습니다. */
|
|
8
|
+
export const tablesField = StateField.define({
|
|
9
|
+
create: scanTables,
|
|
10
|
+
update(value, tr) {
|
|
11
|
+
if (tr.docChanged || tr.reconfigured || syntaxTree(tr.startState) !== syntaxTree(tr.state))
|
|
12
|
+
return scanTables(tr.state);
|
|
13
|
+
return value;
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
/** pos에서 시작하는(Table 노드 시작이 pos인) 표 */
|
|
17
|
+
export function tableBlockAt(state, from) {
|
|
18
|
+
const tables = state.field(tablesField, false);
|
|
19
|
+
if (!tables)
|
|
20
|
+
return null;
|
|
21
|
+
let lo = 0;
|
|
22
|
+
let hi = tables.length - 1;
|
|
23
|
+
while (lo <= hi) {
|
|
24
|
+
const mid = (lo + hi) >> 1;
|
|
25
|
+
const table = tables[mid];
|
|
26
|
+
if (table.from === from)
|
|
27
|
+
return table;
|
|
28
|
+
if (table.from < from)
|
|
29
|
+
lo = mid + 1;
|
|
30
|
+
else
|
|
31
|
+
hi = mid - 1;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export const setTableSelection = StateEffect.define({
|
|
36
|
+
map: (value, mapping) => value && { ...value, table: mapping.mapPos(value.table, 1) },
|
|
37
|
+
});
|
|
38
|
+
export const tableSelectionField = StateField.define({
|
|
39
|
+
create: () => null,
|
|
40
|
+
update(value, tr) {
|
|
41
|
+
for (const effect of tr.effects)
|
|
42
|
+
if (effect.is(setTableSelection))
|
|
43
|
+
return effect.value;
|
|
44
|
+
if (value && tr.docChanged)
|
|
45
|
+
value = { ...value, table: tr.changes.mapPos(value.table, 1) };
|
|
46
|
+
// 표가 사라졌으면 해제합니다.
|
|
47
|
+
if (value && !tableBlockAt(tr.state, value.table))
|
|
48
|
+
return null;
|
|
49
|
+
return value;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// 칸 위치와 칸 범위 선택
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/** pos가 있는 칸: [행 번호(0: 머리글 줄), 열 번호]. 표 행이 아니면 null */
|
|
56
|
+
export function cellIndexAt(state, pos) {
|
|
57
|
+
const found = tableRowAt(state, pos);
|
|
58
|
+
if (!found)
|
|
59
|
+
return null;
|
|
60
|
+
let row = -1;
|
|
61
|
+
let i = 0;
|
|
62
|
+
for (let child = found.table.firstChild; child; child = child.nextSibling) {
|
|
63
|
+
if (child.name !== "TableHeader" && child.name !== "TableRow")
|
|
64
|
+
continue;
|
|
65
|
+
if (child.from === found.row.from) {
|
|
66
|
+
row = i;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
const { cells } = found.row;
|
|
72
|
+
let column = cells.findIndex((c) => pos <= c.to);
|
|
73
|
+
if (column < 0)
|
|
74
|
+
column = Math.max(0, cells.length - 1);
|
|
75
|
+
return row < 0 ? null : { table: found.table.from, row, column };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 선택이 같은 표의 두 칸 이상에 걸치면 그 사각형 칸 범위 (Notion처럼 칸 단위로 고름). 아니면 null.
|
|
79
|
+
* 한 칸 안의 선택은 글자 선택입니다.
|
|
80
|
+
*/
|
|
81
|
+
export function selectedCells(state) {
|
|
82
|
+
const { main } = state.selection;
|
|
83
|
+
if (main.empty || state.selection.ranges.length > 1)
|
|
84
|
+
return null;
|
|
85
|
+
const a = cellIndexAt(state, main.anchor);
|
|
86
|
+
const b = cellIndexAt(state, main.head);
|
|
87
|
+
if (!a || !b || a.table !== b.table || (a.row === b.row && a.column === b.column))
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
table: a.table,
|
|
91
|
+
fromRow: Math.min(a.row, b.row),
|
|
92
|
+
toRow: Math.max(a.row, b.row),
|
|
93
|
+
fromColumn: Math.min(a.column, b.column),
|
|
94
|
+
toColumn: Math.max(a.column, b.column),
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/task.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Extension } from "@codemirror/state";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
export declare const taskPlugin: import("@codemirror/view").ViewPlugin<import("./extensions.js").RevealPluginValue, undefined>;
|
|
4
|
+
/** pos가 있는 줄의 (불릿) 작업 항목을 체크/해제합니다. 작업 항목이 아니면 false */
|
|
5
|
+
export declare function toggleTask(view: EditorView, pos: number): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* GFM 작업 목록(`- [ ] 할 일`)을 체크박스로 보여주는 확장입니다.
|
|
8
|
+
* (번호 목록의 작업 항목 `1. [ ] 할 일`은 다루지 않고 원문 그대로 둡니다.)
|
|
9
|
+
* - 체크박스를 클릭하면 원문의 `[ ]` ↔ `[x]`가 바뀝니다.
|
|
10
|
+
* - 커서가 기호에 닿으면 원래 기호를 보여줍니다.
|
|
11
|
+
* - list()와 함께 쓰면 불릿 작업 항목의 불릿 대신 체크박스만 보여줍니다.
|
|
12
|
+
*/
|
|
13
|
+
export declare function task(): Extension;
|
package/dist/task.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { syntaxTree } from "@codemirror/language";
|
|
2
|
+
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
|
3
|
+
import { taskMarkersHandled } from "./list.js";
|
|
4
|
+
import { createRevealPlugin } from "./utils/reveal.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 체크박스 위젯
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
class TaskWidget extends WidgetType {
|
|
9
|
+
checked;
|
|
10
|
+
constructor(checked) {
|
|
11
|
+
super();
|
|
12
|
+
this.checked = checked;
|
|
13
|
+
}
|
|
14
|
+
eq(other) {
|
|
15
|
+
return other.checked === this.checked;
|
|
16
|
+
}
|
|
17
|
+
toDOM() {
|
|
18
|
+
// <label>로 감싸면 라벨 클릭 시 브라우저가 체크 상태만 바꿔 원문과 어긋날 수 있으므로 span을 씁니다.
|
|
19
|
+
const wrap = document.createElement("span");
|
|
20
|
+
wrap.className = "cm-task-checkbox";
|
|
21
|
+
const box = wrap.appendChild(document.createElement("input"));
|
|
22
|
+
box.type = "checkbox";
|
|
23
|
+
box.checked = this.checked;
|
|
24
|
+
return wrap;
|
|
25
|
+
}
|
|
26
|
+
// 클릭 이벤트를 에디터(mousedown 핸들러)가 받도록 합니다.
|
|
27
|
+
ignoreEvent() {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// 데코레이션 객체는 모듈 로드 시 한 번만 만들고 재사용합니다.
|
|
32
|
+
const checkbox = {
|
|
33
|
+
checked: Decoration.replace({ widget: new TaskWidget(true) }),
|
|
34
|
+
unchecked: Decoration.replace({ widget: new TaskWidget(false) }),
|
|
35
|
+
};
|
|
36
|
+
const revealedMarker = Decoration.mark({ class: "cm-formatting cm-formatting-task" });
|
|
37
|
+
const taskLine = Decoration.line({ class: "cm-task-item" });
|
|
38
|
+
const taskLineChecked = Decoration.line({ class: "cm-task-item cm-task-item-checked" });
|
|
39
|
+
const isCheckedMarker = (text) => text === "[x]" || text === "[X]";
|
|
40
|
+
/**
|
|
41
|
+
* 작업 항목을 담을 수 있는 컨테이너 블록 (그 외 노드는 자식 순회 생략)
|
|
42
|
+
* 번호 목록 안에 중첩된 불릿 작업 항목도 찾아야 하므로 OrderedList도 포함합니다.
|
|
43
|
+
*/
|
|
44
|
+
const CONTAINERS = new Set([
|
|
45
|
+
"Document",
|
|
46
|
+
"Blockquote",
|
|
47
|
+
"BulletList",
|
|
48
|
+
"OrderedList",
|
|
49
|
+
"ListItem",
|
|
50
|
+
]);
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// 플러그인
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
export const taskPlugin = createRevealPlugin({
|
|
55
|
+
// 줄 데코레이션과 대체 데코레이션이 같은 위치에서 섞일 수 있으므로 정렬합니다.
|
|
56
|
+
sort: true,
|
|
57
|
+
visit({ state, decorations, isRevealed }) {
|
|
58
|
+
const { doc } = state;
|
|
59
|
+
return {
|
|
60
|
+
enter(node) {
|
|
61
|
+
if (node.name === "Task") {
|
|
62
|
+
const marker = node.node.getChild("TaskMarker");
|
|
63
|
+
const item = node.node.parent;
|
|
64
|
+
const listMark = item?.getChild("ListMark");
|
|
65
|
+
if (!marker || !item || !listMark)
|
|
66
|
+
return false;
|
|
67
|
+
// 번호 목록의 작업 항목(`1. [ ] 할 일`)은 다루지 않습니다. (원문 그대로 표시)
|
|
68
|
+
if (item.parent?.name !== "BulletList")
|
|
69
|
+
return false;
|
|
70
|
+
const checked = isCheckedMarker(doc.sliceString(marker.from, marker.to));
|
|
71
|
+
decorations.push((checked ? taskLineChecked : taskLine).range(doc.lineAt(node.from).from));
|
|
72
|
+
// 기호부터 [ ]까지 체크박스로 대체 ("- [ ]"처럼 공백이 여러 개여도 정확히)
|
|
73
|
+
const from = listMark.from;
|
|
74
|
+
if (isRevealed(from, marker.to)) {
|
|
75
|
+
decorations.push(revealedMarker.range(from, marker.to));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
decorations.push((checked ? checkbox.checked : checkbox.unchecked).range(from, marker.to));
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
// 컨테이너 블록만 내려가고, 문단/인라인 등은 순회하지 않습니다.
|
|
83
|
+
return CONTAINERS.has(node.name) ? undefined : false;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// 체크 상태 토글
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
/** pos가 있는 줄의 (불릿) 작업 항목 체크 상태를 바꾸는 트랜잭션 스펙. 작업 항목이 없으면 null */
|
|
92
|
+
function toggleSpec(state, pos) {
|
|
93
|
+
const line = state.doc.lineAt(pos);
|
|
94
|
+
let marker = null;
|
|
95
|
+
syntaxTree(state).iterate({
|
|
96
|
+
from: line.from,
|
|
97
|
+
to: line.to,
|
|
98
|
+
enter(node) {
|
|
99
|
+
if (node.name === "TaskMarker" && node.from >= line.from) {
|
|
100
|
+
// TaskMarker → Task → ListItem → BulletList (번호 목록의 작업 항목은 제외)
|
|
101
|
+
if (node.node.parent?.parent?.parent?.name === "BulletList") {
|
|
102
|
+
marker = { from: node.from, to: node.to };
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const found = marker;
|
|
109
|
+
if (!found)
|
|
110
|
+
return null;
|
|
111
|
+
const checked = isCheckedMarker(state.doc.sliceString(found.from, found.to));
|
|
112
|
+
// "[ ]" ↔ "[x]" : 가운데 한 글자만 바꿉니다.
|
|
113
|
+
return {
|
|
114
|
+
changes: { from: found.from + 1, to: found.from + 2, insert: checked ? " " : "x" },
|
|
115
|
+
userEvent: "input.toggle",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** pos가 있는 줄의 (불릿) 작업 항목을 체크/해제합니다. 작업 항목이 아니면 false */
|
|
119
|
+
export function toggleTask(view, pos) {
|
|
120
|
+
const spec = toggleSpec(view.state, pos);
|
|
121
|
+
if (!spec)
|
|
122
|
+
return false;
|
|
123
|
+
view.dispatch(spec);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const taskEvents = EditorView.domEventHandlers({
|
|
127
|
+
mousedown(event, view) {
|
|
128
|
+
const target = event.target;
|
|
129
|
+
const box = target?.closest?.(".cm-task-checkbox");
|
|
130
|
+
if (!box || !view.contentDOM.contains(box))
|
|
131
|
+
return false;
|
|
132
|
+
// 위젯의 DOM 위치는 위젯 시작 위치를 가리키므로, 그 줄의 TaskMarker를 트리에서 찾아 토글합니다.
|
|
133
|
+
if (!toggleTask(view, view.posAtDOM(box)))
|
|
134
|
+
return false;
|
|
135
|
+
event.preventDefault(); // 커서 이동/포커스 변경 없이 토글만 합니다.
|
|
136
|
+
return true;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// 테마
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
const taskTheme = EditorView.baseTheme({
|
|
143
|
+
".cm-task-checkbox input[type='checkbox']": {
|
|
144
|
+
position: "relative",
|
|
145
|
+
top: "2px",
|
|
146
|
+
width: "13.5px",
|
|
147
|
+
height: "13.5px",
|
|
148
|
+
margin: "0",
|
|
149
|
+
transformOrigin: "center",
|
|
150
|
+
transform: "scale(1.1)",
|
|
151
|
+
border: "1px solid #939393",
|
|
152
|
+
cursor: "pointer",
|
|
153
|
+
outline: "none",
|
|
154
|
+
appearance: "none",
|
|
155
|
+
borderRadius: "50%",
|
|
156
|
+
transition: "100ms",
|
|
157
|
+
},
|
|
158
|
+
".cm-task-checkbox input[type='checkbox']:not(:checked):hover": {
|
|
159
|
+
borderColor: "#d3d3d3",
|
|
160
|
+
},
|
|
161
|
+
".cm-task-checkbox input[type='checkbox']:checked": {
|
|
162
|
+
backgroundPosition: "center",
|
|
163
|
+
backgroundSize: "75%",
|
|
164
|
+
backgroundRepeat: "no-repeat",
|
|
165
|
+
backgroundColor: "#008cff",
|
|
166
|
+
borderColor: "#008cff",
|
|
167
|
+
backgroundImage: 'url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iNC41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNoZWNrIj48cGF0aCBkPSJNMjAgNiA5IDE3bC01LTUiLz48L3N2Zz4=")',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
/**
|
|
171
|
+
* GFM 작업 목록(`- [ ] 할 일`)을 체크박스로 보여주는 확장입니다.
|
|
172
|
+
* (번호 목록의 작업 항목 `1. [ ] 할 일`은 다루지 않고 원문 그대로 둡니다.)
|
|
173
|
+
* - 체크박스를 클릭하면 원문의 `[ ]` ↔ `[x]`가 바뀝니다.
|
|
174
|
+
* - 커서가 기호에 닿으면 원래 기호를 보여줍니다.
|
|
175
|
+
* - list()와 함께 쓰면 불릿 작업 항목의 불릿 대신 체크박스만 보여줍니다.
|
|
176
|
+
*/
|
|
177
|
+
export function task() {
|
|
178
|
+
return [taskPlugin, taskEvents, taskTheme, taskMarkersHandled.of(true)];
|
|
179
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VS Code(TextMate) 색 테마 규칙 해석기.
|
|
3
|
+
*
|
|
4
|
+
* VS Code가 쓰는 vscode-textmate의 테마 매칭 방식을 그대로 따릅니다.
|
|
5
|
+
* - 규칙의 스코프 선택자 `a.b c.d`는 마지막 스코프(`c.d`)가 대상, 앞쪽(`a.b`)은 조상 조건입니다.
|
|
6
|
+
* - 스코프는 접두어 단위로 맞춥니다: `keyword`는 `keyword.control.ts`와 맞습니다.
|
|
7
|
+
* - 더 구체적인 규칙(점 단위 깊이 → 조상 조건)이 이기고, 덜 구체적인 규칙의 설정을 이어받습니다.
|
|
8
|
+
* - 토큰의 스코프 스택(바깥 → 안쪽)을 차례로 적용해, 안쪽 스코프의 규칙이 바깥 것을 덮어씁니다.
|
|
9
|
+
*/
|
|
10
|
+
/** 글꼴 모양 비트 (VS Code와 같은 값) */
|
|
11
|
+
export declare const FontStyle: {
|
|
12
|
+
readonly NotSet: -1;
|
|
13
|
+
readonly None: 0;
|
|
14
|
+
readonly Italic: 1;
|
|
15
|
+
readonly Bold: 2;
|
|
16
|
+
readonly Underline: 4;
|
|
17
|
+
readonly Strikethrough: 8;
|
|
18
|
+
};
|
|
19
|
+
/** VS Code 테마의 tokenColors 항목 */
|
|
20
|
+
export interface TokenColorRule {
|
|
21
|
+
readonly name?: string;
|
|
22
|
+
readonly scope?: string | readonly string[];
|
|
23
|
+
readonly settings?: {
|
|
24
|
+
readonly foreground?: string;
|
|
25
|
+
readonly fontStyle?: string;
|
|
26
|
+
readonly background?: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** 해석 결과 */
|
|
30
|
+
export interface TokenStyle {
|
|
31
|
+
/** 글자 색 (소문자 `#rrggbb` 또는 `#rrggbbaa` 등). 없으면 null */
|
|
32
|
+
readonly foreground: string | null;
|
|
33
|
+
/** FontStyle 비트 조합 */
|
|
34
|
+
readonly fontStyle: number;
|
|
35
|
+
}
|
|
36
|
+
export interface TextMateTheme {
|
|
37
|
+
/** 스코프 스택(바깥 → 안쪽)의 스타일을 구합니다. */
|
|
38
|
+
resolve(scopes: readonly string[]): TokenStyle;
|
|
39
|
+
/** 기본 글자 색 */
|
|
40
|
+
readonly foreground: string | null;
|
|
41
|
+
/** 규칙에 쓰인 모든 글자 색 */
|
|
42
|
+
readonly colors: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
/** tokenColors 규칙으로 테마 해석기를 만듭니다. */
|
|
45
|
+
export declare function textMateTheme(rules: readonly TokenColorRule[], defaultForeground?: string): TextMateTheme;
|
|
46
|
+
/**
|
|
47
|
+
* JSONC(주석, 끝 쉼표 허용 JSON)를 읽습니다. 문자열 안의 `//`, `/*`는 건드리지 않습니다.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseJsonc(text: string): unknown;
|
package/dist/textMate.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VS Code(TextMate) 색 테마 규칙 해석기.
|
|
3
|
+
*
|
|
4
|
+
* VS Code가 쓰는 vscode-textmate의 테마 매칭 방식을 그대로 따릅니다.
|
|
5
|
+
* - 규칙의 스코프 선택자 `a.b c.d`는 마지막 스코프(`c.d`)가 대상, 앞쪽(`a.b`)은 조상 조건입니다.
|
|
6
|
+
* - 스코프는 접두어 단위로 맞춥니다: `keyword`는 `keyword.control.ts`와 맞습니다.
|
|
7
|
+
* - 더 구체적인 규칙(점 단위 깊이 → 조상 조건)이 이기고, 덜 구체적인 규칙의 설정을 이어받습니다.
|
|
8
|
+
* - 토큰의 스코프 스택(바깥 → 안쪽)을 차례로 적용해, 안쪽 스코프의 규칙이 바깥 것을 덮어씁니다.
|
|
9
|
+
*/
|
|
10
|
+
/** 글꼴 모양 비트 (VS Code와 같은 값) */
|
|
11
|
+
export const FontStyle = {
|
|
12
|
+
NotSet: -1,
|
|
13
|
+
None: 0,
|
|
14
|
+
Italic: 1,
|
|
15
|
+
Bold: 2,
|
|
16
|
+
Underline: 4,
|
|
17
|
+
Strikethrough: 8,
|
|
18
|
+
};
|
|
19
|
+
const COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
20
|
+
function parseFontStyle(value) {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
return FontStyle.NotSet;
|
|
23
|
+
let style = FontStyle.None;
|
|
24
|
+
for (const word of value.split(/\s+/)) {
|
|
25
|
+
if (word === "italic")
|
|
26
|
+
style |= FontStyle.Italic;
|
|
27
|
+
else if (word === "bold")
|
|
28
|
+
style |= FontStyle.Bold;
|
|
29
|
+
else if (word === "underline")
|
|
30
|
+
style |= FontStyle.Underline;
|
|
31
|
+
else if (word === "strikethrough")
|
|
32
|
+
style |= FontStyle.Strikethrough;
|
|
33
|
+
}
|
|
34
|
+
return style;
|
|
35
|
+
}
|
|
36
|
+
const parseColor = (value) => typeof value === "string" && COLOR.test(value) ? value.toLowerCase() : null;
|
|
37
|
+
/** 조상 조건 배열 비교 (vscode-textmate strArrCmp: null < 배열, 짧은 쪽 먼저) */
|
|
38
|
+
function compareArrays(a, b) {
|
|
39
|
+
if (a === null && b === null)
|
|
40
|
+
return 0;
|
|
41
|
+
if (!a)
|
|
42
|
+
return -1;
|
|
43
|
+
if (!b)
|
|
44
|
+
return 1;
|
|
45
|
+
if (a.length !== b.length)
|
|
46
|
+
return a.length - b.length;
|
|
47
|
+
for (let i = 0; i < a.length; i++) {
|
|
48
|
+
if (a[i] !== b[i])
|
|
49
|
+
return a[i] < b[i] ? -1 : 1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
class TrieRule {
|
|
54
|
+
scopeDepth;
|
|
55
|
+
parentScopes;
|
|
56
|
+
fontStyle;
|
|
57
|
+
foreground;
|
|
58
|
+
constructor(scopeDepth, parentScopes, fontStyle, foreground) {
|
|
59
|
+
this.scopeDepth = scopeDepth;
|
|
60
|
+
this.parentScopes = parentScopes;
|
|
61
|
+
this.fontStyle = fontStyle;
|
|
62
|
+
this.foreground = foreground;
|
|
63
|
+
}
|
|
64
|
+
clone() {
|
|
65
|
+
return new TrieRule(this.scopeDepth, this.parentScopes, this.fontStyle, this.foreground);
|
|
66
|
+
}
|
|
67
|
+
acceptOverwrite(scopeDepth, fontStyle, foreground) {
|
|
68
|
+
this.scopeDepth = Math.max(this.scopeDepth, scopeDepth);
|
|
69
|
+
if (fontStyle !== FontStyle.NotSet)
|
|
70
|
+
this.fontStyle = fontStyle;
|
|
71
|
+
if (foreground)
|
|
72
|
+
this.foreground = foreground;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** 구체적인 규칙이 앞으로 (vscode-textmate _cmpBySpecificity) */
|
|
76
|
+
function bySpecificity(a, b) {
|
|
77
|
+
if (a.scopeDepth !== b.scopeDepth)
|
|
78
|
+
return b.scopeDepth - a.scopeDepth;
|
|
79
|
+
let ai = 0;
|
|
80
|
+
let bi = 0;
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (a.parentScopes[ai] === ">")
|
|
83
|
+
ai++;
|
|
84
|
+
if (b.parentScopes[bi] === ">")
|
|
85
|
+
bi++;
|
|
86
|
+
if (ai >= a.parentScopes.length || bi >= b.parentScopes.length)
|
|
87
|
+
break;
|
|
88
|
+
const diff = b.parentScopes[bi].length - a.parentScopes[ai].length;
|
|
89
|
+
if (diff !== 0)
|
|
90
|
+
return diff;
|
|
91
|
+
ai++;
|
|
92
|
+
bi++;
|
|
93
|
+
}
|
|
94
|
+
return b.parentScopes.length - a.parentScopes.length;
|
|
95
|
+
}
|
|
96
|
+
class TrieNode {
|
|
97
|
+
mainRule;
|
|
98
|
+
parentRules;
|
|
99
|
+
children = new Map();
|
|
100
|
+
constructor(mainRule, parentRules = []) {
|
|
101
|
+
this.mainRule = mainRule;
|
|
102
|
+
this.parentRules = parentRules;
|
|
103
|
+
}
|
|
104
|
+
match(scope) {
|
|
105
|
+
if (scope !== "") {
|
|
106
|
+
const dot = scope.indexOf(".");
|
|
107
|
+
const head = dot < 0 ? scope : scope.slice(0, dot);
|
|
108
|
+
const child = this.children.get(head);
|
|
109
|
+
if (child)
|
|
110
|
+
return child.match(dot < 0 ? "" : scope.slice(dot + 1));
|
|
111
|
+
}
|
|
112
|
+
return [...this.parentRules, this.mainRule].sort(bySpecificity);
|
|
113
|
+
}
|
|
114
|
+
insert(depth, scope, parents, fontStyle, fg) {
|
|
115
|
+
if (scope === "") {
|
|
116
|
+
this.insertHere(depth, parents, fontStyle, fg);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const dot = scope.indexOf(".");
|
|
120
|
+
const head = dot < 0 ? scope : scope.slice(0, dot);
|
|
121
|
+
let child = this.children.get(head);
|
|
122
|
+
if (!child) {
|
|
123
|
+
// 새 자식은 지금까지의 설정을 이어받습니다.
|
|
124
|
+
child = new TrieNode(this.mainRule.clone(), this.parentRules.map((r) => r.clone()));
|
|
125
|
+
this.children.set(head, child);
|
|
126
|
+
}
|
|
127
|
+
child.insert(depth + 1, dot < 0 ? "" : scope.slice(dot + 1), parents, fontStyle, fg);
|
|
128
|
+
}
|
|
129
|
+
insertHere(depth, parents, fontStyle, fg) {
|
|
130
|
+
if (parents === null) {
|
|
131
|
+
this.mainRule.acceptOverwrite(depth, fontStyle, fg);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const rule of this.parentRules) {
|
|
135
|
+
if (compareArrays(rule.parentScopes, parents) === 0) {
|
|
136
|
+
rule.acceptOverwrite(depth, fontStyle, fg);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// 같은 조상 조건의 규칙이 없으면 대상 스코프의 기본 설정을 이어받아 새로 만듭니다.
|
|
141
|
+
this.parentRules.push(new TrieRule(depth, parents, fontStyle === FontStyle.NotSet ? this.mainRule.fontStyle : fontStyle, fg ?? this.mainRule.foreground));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const matchesScope = (scope, pattern) => scope === pattern || (scope.startsWith(pattern) && scope.charCodeAt(pattern.length) === 46); /* . */
|
|
145
|
+
/** 조상 조건(가까운 조상부터)이 스코프 경로(바깥 → 안쪽)에 차례로 있는지 */
|
|
146
|
+
function matchesParents(path, end, parents) {
|
|
147
|
+
let i = end - 1;
|
|
148
|
+
for (let p = 0; p < parents.length; p++) {
|
|
149
|
+
let pattern = parents[p];
|
|
150
|
+
let mustMatch = false;
|
|
151
|
+
if (pattern === ">") {
|
|
152
|
+
if (p === parents.length - 1)
|
|
153
|
+
return false;
|
|
154
|
+
pattern = parents[++p];
|
|
155
|
+
mustMatch = true;
|
|
156
|
+
}
|
|
157
|
+
while (i >= 0 && !matchesScope(path[i], pattern)) {
|
|
158
|
+
if (mustMatch)
|
|
159
|
+
return false;
|
|
160
|
+
i--;
|
|
161
|
+
}
|
|
162
|
+
if (i < 0)
|
|
163
|
+
return false;
|
|
164
|
+
i--;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
/** tokenColors 규칙으로 테마 해석기를 만듭니다. */
|
|
169
|
+
export function textMateTheme(rules, defaultForeground) {
|
|
170
|
+
const parsed = [];
|
|
171
|
+
rules.forEach((rule, index) => {
|
|
172
|
+
const settings = rule?.settings;
|
|
173
|
+
if (!settings || typeof settings !== "object")
|
|
174
|
+
return;
|
|
175
|
+
const scopes = typeof rule.scope === "string"
|
|
176
|
+
? rule.scope.replace(/^,+|,+$/g, "").split(",")
|
|
177
|
+
: Array.isArray(rule.scope)
|
|
178
|
+
? rule.scope
|
|
179
|
+
: [""];
|
|
180
|
+
const fontStyle = parseFontStyle(settings.fontStyle);
|
|
181
|
+
const foreground = parseColor(settings.foreground);
|
|
182
|
+
for (const selector of scopes) {
|
|
183
|
+
const segments = String(selector).trim().split(/\s+/).filter(Boolean);
|
|
184
|
+
const scope = segments.length ? segments[segments.length - 1] : "";
|
|
185
|
+
const parents = segments.length > 1 ? segments.slice(0, -1).reverse() : null;
|
|
186
|
+
parsed.push({ scope, parents, index, fontStyle, foreground });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
parsed.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0) ||
|
|
190
|
+
compareArrays(a.parents, b.parents) ||
|
|
191
|
+
a.index - b.index);
|
|
192
|
+
// 스코프 없는 규칙 = 전역 기본값
|
|
193
|
+
let defaultFontStyle = FontStyle.None;
|
|
194
|
+
let foreground = parseColor(defaultForeground);
|
|
195
|
+
while (parsed.length && parsed[0].scope === "") {
|
|
196
|
+
const rule = parsed.shift();
|
|
197
|
+
if (rule.fontStyle !== FontStyle.NotSet)
|
|
198
|
+
defaultFontStyle = rule.fontStyle;
|
|
199
|
+
if (rule.foreground)
|
|
200
|
+
foreground = rule.foreground;
|
|
201
|
+
}
|
|
202
|
+
const root = new TrieNode(new TrieRule(0, [], FontStyle.NotSet, null));
|
|
203
|
+
for (const rule of parsed)
|
|
204
|
+
root.insert(0, rule.scope, rule.parents, rule.fontStyle, rule.foreground);
|
|
205
|
+
const colors = new Set();
|
|
206
|
+
if (foreground)
|
|
207
|
+
colors.add(foreground);
|
|
208
|
+
for (const rule of parsed)
|
|
209
|
+
if (rule.foreground)
|
|
210
|
+
colors.add(rule.foreground);
|
|
211
|
+
return {
|
|
212
|
+
foreground,
|
|
213
|
+
colors: [...colors],
|
|
214
|
+
resolve(scopes) {
|
|
215
|
+
let fontStyle = defaultFontStyle;
|
|
216
|
+
let fg = foreground;
|
|
217
|
+
for (let i = 0; i < scopes.length; i++) {
|
|
218
|
+
for (const rule of root.match(scopes[i])) {
|
|
219
|
+
if (!matchesParents(scopes, i, rule.parentScopes))
|
|
220
|
+
continue;
|
|
221
|
+
if (rule.fontStyle !== FontStyle.NotSet)
|
|
222
|
+
fontStyle = rule.fontStyle;
|
|
223
|
+
if (rule.foreground)
|
|
224
|
+
fg = rule.foreground;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { foreground: fg, fontStyle };
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* JSONC(주석, 끝 쉼표 허용 JSON)를 읽습니다. 문자열 안의 `//`, `/*`는 건드리지 않습니다.
|
|
234
|
+
*/
|
|
235
|
+
export function parseJsonc(text) {
|
|
236
|
+
let out = "";
|
|
237
|
+
let i = 0;
|
|
238
|
+
const n = text.length;
|
|
239
|
+
while (i < n) {
|
|
240
|
+
const ch = text[i];
|
|
241
|
+
if (ch === '"') {
|
|
242
|
+
// 문자열: 이스케이프를 지키며 통째로 복사
|
|
243
|
+
let j = i + 1;
|
|
244
|
+
while (j < n && text[j] !== '"')
|
|
245
|
+
j += text[j] === "\\" ? 2 : 1;
|
|
246
|
+
out += text.slice(i, j + 1);
|
|
247
|
+
i = j + 1;
|
|
248
|
+
}
|
|
249
|
+
else if (ch === "/" && text[i + 1] === "/") {
|
|
250
|
+
while (i < n && text[i] !== "\n")
|
|
251
|
+
i++;
|
|
252
|
+
}
|
|
253
|
+
else if (ch === "/" && text[i + 1] === "*") {
|
|
254
|
+
const end = text.indexOf("*/", i + 2);
|
|
255
|
+
i = end < 0 ? n : end + 2;
|
|
256
|
+
}
|
|
257
|
+
else if (ch === ",") {
|
|
258
|
+
// 끝 쉼표: 다음 의미 있는 글자가 } 또는 ]이면 버립니다.
|
|
259
|
+
let j = i + 1;
|
|
260
|
+
for (;;) {
|
|
261
|
+
while (j < n && /\s/.test(text[j]))
|
|
262
|
+
j++;
|
|
263
|
+
if (text[j] === "/" && text[j + 1] === "/") {
|
|
264
|
+
while (j < n && text[j] !== "\n")
|
|
265
|
+
j++;
|
|
266
|
+
}
|
|
267
|
+
else if (text[j] === "/" && text[j + 1] === "*") {
|
|
268
|
+
const end = text.indexOf("*/", j + 2);
|
|
269
|
+
j = end < 0 ? n : end + 2;
|
|
270
|
+
}
|
|
271
|
+
else
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (text[j] !== "}" && text[j] !== "]")
|
|
275
|
+
out += ch;
|
|
276
|
+
i++;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
out += ch;
|
|
280
|
+
i++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return JSON.parse(out);
|
|
284
|
+
}
|
package/dist/tikz.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Extension } from "@codemirror/state";
|
|
2
|
+
export interface TikzDocument {
|
|
3
|
+
/** 코드 블록 원문 */
|
|
4
|
+
readonly source: string;
|
|
5
|
+
/** `\begin{document}`와 `\end{document}` 사이 (없으면 원문에서 패키지/라이브러리 선언을 뺀 나머지) */
|
|
6
|
+
readonly body: string;
|
|
7
|
+
/** `\usepackage[옵션]{이름}` → { 이름: 옵션 } (tikz, xcolor, pgf는 렌더러가 이미 불러오므로 제외) */
|
|
8
|
+
readonly packages: Readonly<Record<string, string>>;
|
|
9
|
+
/** `\usetikzlibrary{…}`의 라이브러리 이름들 */
|
|
10
|
+
readonly libraries: readonly string[];
|
|
11
|
+
/** 그 밖의 전처리 명령 (`\begin{document}` 앞, 패키지/라이브러리 선언 제외) */
|
|
12
|
+
readonly preamble: string;
|
|
13
|
+
}
|
|
14
|
+
/** TikZ 코드 블록 원문을 렌더러가 쓸 수 있게 나눕니다. */
|
|
15
|
+
export declare function parseTikz(source: string): TikzDocument;
|
|
16
|
+
/** TikZ 문서를 SVG 마크업으로 바꿉니다. (실패하면 거부) */
|
|
17
|
+
export type TikzRenderer = (doc: TikzDocument) => Promise<string>;
|
|
18
|
+
export interface TikzJaxOptions {
|
|
19
|
+
/**
|
|
20
|
+
* tikzjax.js 주소. 같은 폴더에서 TeX 엔진(run-tex.js, tex.wasm.gz, core.dump.gz, tex_files/)을 불러오므로
|
|
21
|
+
* `@drgrice1/tikzjax`의 dist 폴더를 통째로 같은 출처에서 제공해야 합니다. (기본 "/tikzjax/tikzjax.js")
|
|
22
|
+
*/
|
|
23
|
+
src?: string;
|
|
24
|
+
/** 글꼴 CSS 주소 (기본: src와 같은 폴더의 fonts.css). 이미 불러왔다면 false */
|
|
25
|
+
fonts?: string | false;
|
|
26
|
+
/** 한 그림의 최대 렌더링 시간 (ms, 기본 60000) */
|
|
27
|
+
timeout?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* TikZJax(@drgrice1/tikzjax, 브라우저 안의 WebAssembly TeX)로 그리는 렌더러.
|
|
31
|
+
* TikZJax는 GPL-3.0이므로 이 라이브러리에 포함하지 않고, 앱이 제공하는 주소에서 실행할 때 불러옵니다.
|
|
32
|
+
* 처음 한 번 TeX 엔진(약 7MB)을 내려받고, 결과는 TikZJax가 IndexedDB에 캐시합니다.
|
|
33
|
+
*/
|
|
34
|
+
export declare function tikzjaxRenderer(options?: TikzJaxOptions): TikzRenderer;
|
|
35
|
+
/**
|
|
36
|
+
* 렌더러가 돌려준 SVG에서 스크립트와 이벤트 속성, javascript: 링크를 지웁니다.
|
|
37
|
+
* (TeX의 \special로 SVG 조각을 넣을 수 있으므로, 신뢰할 수 없는 문서도 안전하게 보여주기 위함)
|
|
38
|
+
*/
|
|
39
|
+
export declare function sanitizeSvg(markup: string): string;
|
|
40
|
+
export interface TikzOptions {
|
|
41
|
+
/** TikZ → SVG 렌더러 (기본: `tikzjaxRenderer()`) */
|
|
42
|
+
render?: TikzRenderer;
|
|
43
|
+
/** 그림으로 그릴 코드 블록 언어 (기본 ["tikz"]) */
|
|
44
|
+
languages?: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* ```` ```tikz ```` 코드 블록을 그림으로 보여주는 확장입니다.
|
|
48
|
+
* - 원문: Obsidian TikZJax 형식(`\usepackage`, `\usetikzlibrary`, `\begin{document}`…) 또는 tikzpicture만
|
|
49
|
+
* - 커서가 블록 밖이면 그림, 안이면 코드 블록 원문과 그 아래 미리보기
|
|
50
|
+
* - 렌더러는 바꿀 수 있습니다. 기본은 TikZJax(브라우저 WebAssembly TeX)이며, 그 파일은 앱이 제공해야 합니다.
|
|
51
|
+
* ```ts
|
|
52
|
+
* tikz({ render: tikzjaxRenderer({ src: "/tikzjax/tikzjax.js" }) })
|
|
53
|
+
* ```
|
|
54
|
+
* - 같은 원문은 한 번만 렌더링합니다. 그리는 동안에는 진행 표시, 실패하면 오류를 보여줍니다.
|
|
55
|
+
*/
|
|
56
|
+
export declare function tikz(options?: TikzOptions): Extension;
|