mdsquare 0.2.0 → 0.3.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/dist/link.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { syntaxTree } from "@codemirror/language";
2
- import { Prec } from "@codemirror/state";
3
- import { Decoration, EditorView } from "@codemirror/view";
2
+ import { Facet, Prec, StateEffect } from "@codemirror/state";
3
+ import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
4
4
  import { createRevealPlugin } from "./utils/reveal.js";
5
+ import { charWidth } from "./utils/textWidth.js";
5
6
  const unescape = (text) => text.replace(/\\([!-/:-@[-`{-~])/g, "$1");
6
7
  /**
7
8
  * Link 노드에서 인라인 링크 `[label](url "title")` 정보를 읽습니다.
@@ -82,6 +83,245 @@ function renderedLabel(tooltip) {
82
83
  }
83
84
  return deco;
84
85
  }
86
+ // ---------------------------------------------------------------------------
87
+ // 사이트 아이콘(favicon)
88
+ // ---------------------------------------------------------------------------
89
+ /** 기본 아이콘 주소: 사이트의 /favicon.ico (다른 서비스에 주소를 보내지 않음) */
90
+ export function defaultFavicon(url) {
91
+ return `${url.origin}/favicon.ico`;
92
+ }
93
+ /** 불러오지 못한 아이콘 주소 (다시 요청하지 않고 바로 기본 아이콘을 그림) */
94
+ const failedIcons = new Set();
95
+ /** http(s) 주소만 URL로 읽습니다. (상대 경로, mailto: 등은 null) */
96
+ const urlCache = new Map();
97
+ function webUrl(href) {
98
+ let url = urlCache.get(href);
99
+ if (url === undefined) {
100
+ if (urlCache.size >= 1000)
101
+ urlCache.clear();
102
+ try {
103
+ const parsed = new URL(href);
104
+ url = parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : null;
105
+ }
106
+ catch {
107
+ url = null;
108
+ }
109
+ urlCache.set(href, url);
110
+ }
111
+ return url;
112
+ }
113
+ /** 아이콘 DOM: 겉 span은 그대로 두고 안쪽만 바꿉니다. (위젯의 겉 요소를 바꾸면 CodeMirror가 위치를 잃음) */
114
+ function faviconDOM(src) {
115
+ const wrap = document.createElement("span");
116
+ wrap.className = "cm-link-icon";
117
+ const fallback = () => {
118
+ const icon = document.createElement("span");
119
+ icon.className = "cm-link-icon-fallback";
120
+ return icon;
121
+ };
122
+ if (!src || failedIcons.has(src)) {
123
+ wrap.append(fallback());
124
+ return wrap;
125
+ }
126
+ const img = document.createElement("img");
127
+ img.className = "cm-link-favicon";
128
+ img.alt = "";
129
+ img.draggable = false;
130
+ img.decoding = "async";
131
+ img.referrerPolicy = "no-referrer";
132
+ img.addEventListener("error", () => {
133
+ failedIcons.add(src);
134
+ img.replaceWith(fallback());
135
+ }, { once: true });
136
+ img.src = src;
137
+ wrap.append(img);
138
+ return wrap;
139
+ }
140
+ class FaviconWidget extends WidgetType {
141
+ src;
142
+ constructor(src) {
143
+ super();
144
+ this.src = src;
145
+ }
146
+ eq(other) {
147
+ return other.src === this.src;
148
+ }
149
+ toDOM() {
150
+ return faviconDOM(this.src);
151
+ }
152
+ // 아이콘을 눌러도 링크가 열리도록 이벤트를 에디터에 넘깁니다.
153
+ ignoreEvent() {
154
+ return false;
155
+ }
156
+ }
157
+ /** 라벨이 빈 링크(`[](url)`)를 가져온 페이지 제목으로 보여주는 위젯 */
158
+ class FetchedTitleWidget extends WidgetType {
159
+ text;
160
+ tooltip;
161
+ icon;
162
+ constructor(text, tooltip, icon) {
163
+ super();
164
+ this.text = text;
165
+ this.tooltip = tooltip;
166
+ this.icon = icon;
167
+ }
168
+ eq(other) {
169
+ return other.text === this.text && other.tooltip === this.tooltip && other.icon === this.icon;
170
+ }
171
+ toDOM() {
172
+ // 박스 하나에 아이콘과 제목을 함께 넣습니다.
173
+ const box = document.createElement("span");
174
+ box.className = "cm-link cm-link-rendered cm-link-fetched";
175
+ box.title = this.tooltip;
176
+ if (this.icon !== false)
177
+ box.append(faviconDOM(this.icon));
178
+ const label = document.createElement("span");
179
+ label.className = "cm-link-fetched-title";
180
+ label.textContent = this.text;
181
+ box.append(label);
182
+ return box;
183
+ }
184
+ ignoreEvent() {
185
+ return false;
186
+ }
187
+ }
188
+ const faviconCache = new Map();
189
+ function faviconDeco(src) {
190
+ const key = src ?? "";
191
+ let deco = faviconCache.get(key);
192
+ if (!deco) {
193
+ if (faviconCache.size >= 1000)
194
+ faviconCache.clear();
195
+ deco = Decoration.replace({ widget: new FaviconWidget(src) });
196
+ faviconCache.set(key, deco);
197
+ }
198
+ return deco;
199
+ }
200
+ // ---------------------------------------------------------------------------
201
+ // 페이지 제목 가져오기 (라벨이 빈 링크)
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * 글자 폭(넓은 글자는 2칸)이 max를 넘으면 잘라서 끝에 "…"를 붙입니다.
205
+ * 공백은 하나로 줄이고 앞뒤 공백은 없앱니다.
206
+ */
207
+ export function truncateTitle(text, max) {
208
+ const clean = text.replace(/\s+/g, " ").trim();
209
+ const chars = Array.from(clean);
210
+ const widths = chars.map(charWidth);
211
+ if (widths.reduce((a, b) => a + b, 0) <= max)
212
+ return clean;
213
+ // "…" 한 칸을 남겨 두고 자릅니다.
214
+ let width = 0;
215
+ let end = 0;
216
+ while (end < chars.length && width + widths[end] <= max - 1)
217
+ width += widths[end++];
218
+ return `${chars.slice(0, end).join("").trimEnd()}…`;
219
+ }
220
+ /** HTML에서 페이지 제목을 읽습니다. (og:title → twitter:title → <title>) */
221
+ export function parseHtmlTitle(html) {
222
+ // DOMParser 문서는 스크립트를 실행하거나 이미지를 불러오지 않습니다.
223
+ const doc = new DOMParser().parseFromString(html, "text/html");
224
+ const meta = (sel) => doc.querySelector(sel)?.content;
225
+ const title = meta('meta[property="og:title"]') ?? meta('meta[name="twitter:title"]') ?? doc.querySelector("title")?.textContent;
226
+ const clean = title?.replace(/\s+/g, " ").trim();
227
+ return clean ? clean : null;
228
+ }
229
+ /**
230
+ * HTML 바이트를 문자 집합에 맞게 글자로 바꿉니다. (response.text()는 항상 UTF-8로 읽어 EUC-KR 등이 깨짐)
231
+ * Content-Type의 charset → 문서 앞부분의 <meta charset> → UTF-8 순서로 정합니다.
232
+ */
233
+ export function decodeHtml(bytes, contentType) {
234
+ const head = new TextDecoder("latin1").decode(bytes.slice(0, 2048));
235
+ const charset = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType ?? "")?.[1] ??
236
+ /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(head)?.[1] ??
237
+ "utf-8";
238
+ try {
239
+ return new TextDecoder(charset).decode(bytes);
240
+ }
241
+ catch {
242
+ return new TextDecoder().decode(bytes); // 모르는 문자 집합
243
+ }
244
+ }
245
+ /** 페이지 HTML을 받아 제목을 읽습니다. 실패하면 null. (`link({ fetchTitle })`에 넘기기 위한 도우미) */
246
+ export async function fetchPageTitle(url, options = {}) {
247
+ try {
248
+ const response = await fetch(options.proxy ? options.proxy(url) : url, {
249
+ signal: AbortSignal.timeout(options.timeout ?? 8000),
250
+ });
251
+ if (!response.ok)
252
+ return null;
253
+ return parseHtmlTitle(decodeHtml(await response.arrayBuffer(), response.headers.get("content-type")));
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ /** 제목을 다 가져왔음을 알려 데코레이션을 다시 만들게 하는 효과 */
260
+ const linkTitlesLoaded = StateEffect.define();
261
+ /** 링크 제목을 가져오고 기억합니다. (link() 호출마다 하나, 그 확장을 쓰는 모든 에디터가 공유) */
262
+ class TitleLoader {
263
+ fetchTitle;
264
+ /** 가져온 제목 (null: 실패/없음 → 다시 요청하지 않음) */
265
+ titles = new Map();
266
+ pending = new Set();
267
+ flushScheduled = false;
268
+ views = new Set();
269
+ constructor(fetchTitle) {
270
+ this.fetchTitle = fetchTitle;
271
+ }
272
+ /** 가져온 제목. 아직이면 요청하고 undefined */
273
+ get(url) {
274
+ const title = this.titles.get(url);
275
+ if (title !== undefined || this.titles.has(url))
276
+ return title;
277
+ if (!this.pending.has(url)) {
278
+ this.pending.add(url);
279
+ // 데코레이션을 만드는 중에 호출되므로 실제 요청은 그 뒤에 합니다.
280
+ void Promise.resolve()
281
+ .then(() => this.fetchTitle(url))
282
+ .then((title) => title?.replace(/\s+/g, " ").trim() || null, () => null)
283
+ .then((title) => {
284
+ this.pending.delete(url);
285
+ if (this.titles.size >= 1000)
286
+ this.titles.clear();
287
+ this.titles.set(url, title);
288
+ this.scheduleFlush();
289
+ });
290
+ }
291
+ return undefined;
292
+ }
293
+ /** 거의 동시에 도착한 제목은 한 번에 반영합니다. */
294
+ scheduleFlush() {
295
+ if (this.flushScheduled)
296
+ return;
297
+ this.flushScheduled = true;
298
+ setTimeout(() => {
299
+ this.flushScheduled = false;
300
+ for (const view of this.views)
301
+ view.dispatch({ effects: linkTitlesLoaded.of(null) });
302
+ });
303
+ }
304
+ }
305
+ const DEFAULT_MAX_TITLE = 40;
306
+ /** link()의 설정 (linkPlugin만 쓰면 기본값: 아이콘 표시, 제목 가져오기 없음) */
307
+ const linkConfig = Facet.define({
308
+ combine: (values) => values[0] ?? { favicon: defaultFavicon, titles: null, maxTitleLength: DEFAULT_MAX_TITLE },
309
+ });
310
+ /** 제목을 가져오는 에디터를 등록합니다. (제목이 도착하면 그 에디터들을 다시 그림) */
311
+ const titleViews = ViewPlugin.define((view) => {
312
+ let loader = view.state.facet(linkConfig).titles;
313
+ loader?.views.add(view);
314
+ return {
315
+ update(update) {
316
+ const next = update.state.facet(linkConfig).titles;
317
+ if (next === loader)
318
+ return;
319
+ loader?.views.delete(view);
320
+ (loader = next)?.views.add(view);
321
+ },
322
+ destroy: () => loader?.views.delete(view),
323
+ };
324
+ });
85
325
  /** 링크가 올 수 없으므로 자식 순회를 생략하는 노드 */
86
326
  const SKIP_CHILDREN = new Set([
87
327
  "FencedCode",
@@ -95,7 +335,16 @@ const SKIP_CHILDREN = new Set([
95
335
  ]);
96
336
  export const linkPlugin = createRevealPlugin({
97
337
  // 링크 안에서 [ → 라벨 → ] → ( → URL → 제목 → ) 순서로 추가되므로 정렬이 필요 없습니다.
338
+ rebuildOn: (update) => update.transactions.some((tr) => tr.effects.some((e) => e.is(linkTitlesLoaded))),
98
339
  visit({ state, decorations, isRevealed }) {
340
+ const config = state.facet(linkConfig);
341
+ /** 링크 앞에 그릴 아이콘 주소 (false: 그리지 않음, null: 기본 아이콘) */
342
+ const iconFor = (href) => {
343
+ if (!config.favicon)
344
+ return false;
345
+ const url = webUrl(href);
346
+ return url ? config.favicon(url) : false;
347
+ };
99
348
  return {
100
349
  enter(node) {
101
350
  if (node.name === "Link") {
@@ -121,16 +370,41 @@ export const linkPlugin = createRevealPlugin({
121
370
  return false;
122
371
  }
123
372
  const tooltip = link.title ?? link.href;
373
+ const icon = iconFor(link.href);
374
+ /** 앞쪽 숨김 범위: 아이콘이 있으면 그 자리에 아이콘을 그립니다. */
375
+ const lead = (to) => (icon === false ? hidden : faviconDeco(icon)).range(link.from, to);
124
376
  if (hasLabel) {
125
- // [ 숨김 → 라벨(링크 스타일, 안쪽 인라인 문법 유지) → ](url "title") 숨김
126
- decorations.push(hidden.range(link.from, link.labelFrom));
127
- decorations.push(renderedLabel(tooltip).range(link.labelFrom, link.labelTo));
377
+ // [ 숨김(또는 아이콘) → 라벨(링크 스타일, 안쪽 인라인 문법 유지) → ](url "title") 숨김
378
+ // 라벨이 이미지인 링크(`[![](img)](url)`)에는 아이콘을 그리지 않습니다.
379
+ // 박스(라벨 mark)는 아이콘 자리부터 시작해 아이콘과 라벨을 함께 감쌉니다.
380
+ const image = node.node.getChild("Image");
381
+ const boxFrom = image || icon === false ? link.labelFrom : link.from;
382
+ if (boxFrom === link.from)
383
+ decorations.push(renderedLabel(tooltip).range(boxFrom, link.labelTo));
384
+ decorations.push(image ? hidden.range(link.from, link.labelFrom) : lead(link.labelFrom));
385
+ if (boxFrom !== link.from)
386
+ decorations.push(renderedLabel(tooltip).range(boxFrom, link.labelTo));
128
387
  decorations.push(hidden.range(link.labelTo, link.to));
129
388
  }
130
389
  else if (link.urlFrom !== null && link.urlFrom < link.urlTo) {
390
+ const title = config.titles && webUrl(link.href) ? config.titles.get(link.href) : null;
391
+ if (title) {
392
+ // 가져온 페이지 제목(너무 길면 잘라서)으로 링크 전체를 보여줍니다. 툴팁에는 전체 제목과 주소.
393
+ decorations.push(Decoration.replace({
394
+ widget: new FetchedTitleWidget(truncateTitle(title, config.maxTitleLength), `${title}\n${link.href}`, icon),
395
+ }).range(link.from, link.to));
396
+ return false;
397
+ }
131
398
  // 라벨이 비어 있으면(`[](url)`) 링크가 사라지지 않도록 URL을 라벨처럼 보여줍니다.
132
- decorations.push(hidden.range(link.from, link.urlFrom));
133
- decorations.push(renderedLabel(tooltip).range(link.urlFrom, link.urlTo));
399
+ // (제목을 가져오는 중이거나 실패한 경우 포함)
400
+ if (icon === false) {
401
+ decorations.push(lead(link.urlFrom));
402
+ decorations.push(renderedLabel(tooltip).range(link.urlFrom, link.urlTo));
403
+ }
404
+ else {
405
+ decorations.push(renderedLabel(tooltip).range(link.from, link.urlTo));
406
+ decorations.push(lead(link.urlFrom));
407
+ }
134
408
  decorations.push(hidden.range(link.urlTo, link.to));
135
409
  }
136
410
  // 라벨과 URL이 모두 비어 있으면(`[]()`) 원문 그대로 둡니다.
@@ -169,7 +443,8 @@ function linkClickHandler(openLink) {
169
443
  mousedown(event, view) {
170
444
  if (event.button !== 0)
171
445
  return false;
172
- const target = event.target?.closest?.(".cm-link-rendered");
446
+ // 라벨, 가져온 제목, 아이콘
447
+ const target = event.target?.closest?.(".cm-link-rendered, .cm-link-icon");
173
448
  if (!target || !view.contentDOM.contains(target))
174
449
  return false;
175
450
  const link = linkAt(view.state, view.posAtDOM(target));
@@ -185,28 +460,86 @@ function linkClickHandler(openLink) {
185
460
  // ---------------------------------------------------------------------------
186
461
  // 테마
187
462
  // ---------------------------------------------------------------------------
463
+ /** 기본 링크 아이콘 (사슬 모양, 아이콘을 불러오지 못했거나 주소가 아이콘을 주지 않을 때) */
464
+ const LINK_ICON = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E\")";
465
+ /** 링크 글자 선택자: 안쪽 span(구문 강조 색, 제목 색)까지 링크 색으로 */
466
+ const textSelector = (mode) => `&${mode} .cm-link-rendered, &${mode} .cm-link-rendered *`;
188
467
  const linkTheme = EditorView.baseTheme({
468
+ // 박스 안에 아이콘과 라벨. 인라인 코드(회색 박스)와 구분되도록 옅은 파랑 배경 + 파란 글자.
189
469
  ".cm-link-rendered": {
190
470
  padding: "0.15rem 0.25rem",
191
471
  borderRadius: "0.25rem",
192
- transition: "100ms",
472
+ transition: "background-color 100ms",
193
473
  cursor: "pointer",
194
474
  },
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)" },
475
+ // 박스가 있으므로 구문 강조가 안쪽에 그리는 밑줄(링크/주소 태그)은 없앱니다.
476
+ // 취소선 글자는 같은 span에 링크 밑줄과 취소선이 함께 있어 따로 끌 수 없으므로,
477
+ // 취소선은 바깥 요소(.cm-strikethrough)에 다시 그립니다. (바깥 요소의 선은 안쪽 글자 위에도 그려짐)
478
+ // 밑줄 문법(.cm-underline)은 바깥 요소에 그려지므로 그대로 보입니다.
479
+ ".cm-link-rendered *:not(.cm-strikethrough):not(.cm-underline)": {
480
+ textDecoration: "none",
481
+ },
482
+ ".cm-link-rendered .cm-strikethrough": {
483
+ textDecoration: "line-through",
484
+ },
485
+ "&light .cm-link-rendered": { backgroundColor: "rgba(9, 105, 218, 0.08)" },
486
+ "&light .cm-link-rendered:hover": { backgroundColor: "rgba(9, 105, 218, 0.15)" },
487
+ "&dark .cm-link-rendered": { backgroundColor: "rgba(88, 166, 255, 0.15)" },
488
+ "&dark .cm-link-rendered:hover": { backgroundColor: "rgba(88, 166, 255, 0.24)" },
489
+ [textSelector("light")]: { color: "#0969da" },
490
+ [textSelector("dark")]: { color: "#58a6ff" },
491
+ // 사이트 아이콘: 글자 높이에 맞춘 정사각형 (줄 높이를 바꾸지 않도록 세로 여백 없이 가로 간격만)
492
+ ".cm-link-icon": {
493
+ display: "inline-block",
494
+ width: "1em",
495
+ height: "1em",
496
+ marginRight: "0.3em",
497
+ verticalAlign: "-0.15em",
498
+ cursor: "pointer",
499
+ },
500
+ ".cm-link-favicon": {
501
+ display: "block",
502
+ width: "100%",
503
+ height: "100%",
504
+ objectFit: "contain",
505
+ borderRadius: "2px",
506
+ },
507
+ ".cm-link-icon-fallback": {
508
+ display: "block",
509
+ width: "100%",
510
+ height: "100%",
511
+ backgroundColor: "currentColor",
512
+ mask: `${LINK_ICON} center / 85% no-repeat`,
513
+ WebkitMask: `${LINK_ICON} center / 85% no-repeat`,
514
+ },
515
+ "&light .cm-link-icon": { color: "#0969da" },
516
+ "&dark .cm-link-icon": { color: "#58a6ff" },
517
+ // 어두운 배경에서 검은 아이콘(GitHub 등)이 보이도록 밝은 바탕을 깝니다.
518
+ "&dark .cm-link-favicon": {
519
+ boxSizing: "border-box",
520
+ padding: "1px",
521
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
522
+ borderRadius: "3px",
523
+ },
200
524
  });
201
525
  /**
202
526
  * 인라인 링크 `[label](url "title")`를 하이브리드 방식으로 보여주는 확장입니다.
203
527
  * - 커서가 링크에 닿지 않으면 기호와 URL을 숨기고 라벨만 링크 스타일로 보여줍니다.
204
528
  * (라벨 안의 굵게/코드 등 인라인 문법은 그대로 유지)
205
529
  * - 렌더링된 라벨을 클릭하면 링크를 엽니다. (기본: http(s)/mailto/tel/ftp만 새 탭으로)
530
+ * - http(s) 링크 앞에 사이트 아이콘을 보여줍니다. (`favicon`)
531
+ * - 라벨이 빈 링크(`[](url)`)는 `fetchTitle`이 있으면 페이지 제목(길면 잘라서)을, 없으면 주소를 보여줍니다.
206
532
  * - 참조 링크(`[a][ref]`, `[a]`), 자동 링크, 이미지는 다루지 않습니다.
207
533
  */
208
534
  export function link(options = {}) {
535
+ const { favicon = true, fetchTitle, maxTitleLength = DEFAULT_MAX_TITLE } = options;
209
536
  return [
537
+ linkConfig.of({
538
+ favicon: favicon === true ? defaultFavicon : favicon || null,
539
+ titles: fetchTitle ? new TitleLoader(fetchTitle) : null,
540
+ maxTitleLength,
541
+ }),
542
+ titleViews,
210
543
  // 겹치는 mark 데코레이션은 우선순위가 낮은(뒤에 오는) 소스가 바깥쪽에 그려집니다.
211
544
  // 라벨이 굵게/코드 등 다른 플러그인의 span 바깥을 하나의 span으로 감싸도록 우선순위를 낮춥니다.
212
545
  // (그렇지 않으면 라벨이 여러 조각으로 나뉘어 배경/모서리 스타일이 끊어집니다)
package/dist/list.js CHANGED
@@ -62,6 +62,7 @@ const CONTAINERS = new Set([
62
62
  "BulletList",
63
63
  "OrderedList",
64
64
  "ListItem",
65
+ "FootnoteDefinition",
65
66
  ]);
66
67
  // ---------------------------------------------------------------------------
67
68
  // 플러그인
@@ -113,6 +114,44 @@ export const listPlugin = createRevealPlugin({
113
114
  // 테마
114
115
  // ---------------------------------------------------------------------------
115
116
  const listTheme = EditorView.baseTheme({
117
+ // 불릿은 글자(•, ◦, ▪) 대신 CSS 도형으로 그립니다.
118
+ // - 글자는 너무 작고, ◦는 다른 글꼴로 대체되어 한 글자보다 넓어져 들여쓰기가 어긋났습니다.
119
+ // - 너비는 원래 기호(-)와 같은 한 글자(1ch)로 고정해 들여쓰기·줄바꿈 정렬을 유지합니다.
120
+ // - 글자는 DOM에 남겨(복사/접근성) 투명하게만 하고, 색(currentColor)은 도형에 씁니다.
121
+ ".cm-list-bullet": {
122
+ display: "inline-block",
123
+ position: "relative",
124
+ width: "1ch",
125
+ WebkitTextFillColor: "transparent",
126
+ },
127
+ ".cm-list-bullet::after": {
128
+ content: '""',
129
+ position: "absolute",
130
+ left: "50%",
131
+ top: "50%",
132
+ transform: "translate(-50%, -50%)",
133
+ boxSizing: "border-box",
134
+ // 글자 크기의 0.35배 (본문 13px에서 약 4.6px)
135
+ width: "0.35em",
136
+ height: "0.35em",
137
+ },
138
+ // 깊이 1: 채운 원
139
+ ".cm-list-bullet-1::after": {
140
+ borderRadius: "50%",
141
+ backgroundColor: "currentColor",
142
+ },
143
+ // 깊이 2: 빈 원
144
+ ".cm-list-bullet-2::after": {
145
+ borderRadius: "50%",
146
+ border: "1.5px solid currentColor",
147
+ },
148
+ // 깊이 3: 채운 사각형 (같은 크기면 원보다 커 보이므로 원의 약 0.9배)
149
+ ".cm-list-bullet-3::after": {
150
+ width: "0.32em",
151
+ height: "0.32em",
152
+ borderRadius: "1px",
153
+ backgroundColor: "currentColor",
154
+ },
116
155
  "&light .cm-list-bullet, &light .cm-formatting-list": {
117
156
  color: "#6b7280",
118
157
  },
@@ -146,6 +146,7 @@ const CONTAINERS = new Set([
146
146
  "OrderedList",
147
147
  "ListItem",
148
148
  "Blockquote",
149
+ "FootnoteDefinition",
149
150
  ]);
150
151
  /** list와 그 조상/자손 OrderedList들의 첫 항목 기호 위치 (변경 전 트리 기준) */
151
152
  function firstItemMarks(lists) {
package/dist/mention.js CHANGED
@@ -36,13 +36,25 @@ export const mentionPlugin = createRevealPlugin({
36
36
  };
37
37
  },
38
38
  });
39
+ /** 멘션 색: 인라인 코드(회색 배경)·해시태그(진한 파랑)와 구분되는 옅은 보라 배경 + 보라 글자 */
40
+ const MENTION_COLORS = {
41
+ light: { bg: "rgba(144, 101, 176, 0.14)", fg: "#7a4ea3" },
42
+ dark: { bg: "rgba(157, 104, 211, 0.24)", fg: "#cfa8f5" },
43
+ };
44
+ /** 멘션 글자색을 지정하는 선택자: 안쪽 span(구문 강조)까지, 제목 안 기호 규칙(제목 색)보다 우선하도록 구체적으로 */
45
+ const textSelector = (mode) => [".cm-mention-begin", ".cm-mention-end"]
46
+ .flatMap((cls) => [`&${mode} .cm-content .cm-line .cm-mention${cls}`, `&${mode} .cm-content .cm-line .cm-mention${cls} *`])
47
+ .join(", ");
39
48
  const mentionTheme = EditorView.baseTheme({
40
49
  ".cm-mention": {
41
- backgroundColor: "rgba(135, 131, 120, 0.15)",
42
- // `.cm-mention *`로 지정하면 안쪽 span이 있을 때만 적용되어
43
- // (구문 강조 span이 없으면 적용되지 않음) 글자 크기가 바뀌지 않으므로 직접 지정합니다.
44
- fontSize: "0.75rem",
50
+ // 주변 글자의 0.9배 (제목 안에서는 제목 크기를 따라 커짐).
51
+ // `.cm-mention *`가 아니라 여기에 지정해야 안쪽 span 유무와 상관없이 같은 크기가 됩니다.
52
+ fontSize: "0.9em",
45
53
  },
54
+ "&light .cm-mention": { backgroundColor: MENTION_COLORS.light.bg },
55
+ "&dark .cm-mention": { backgroundColor: MENTION_COLORS.dark.bg },
56
+ [textSelector("light")]: { color: MENTION_COLORS.light.fg },
57
+ [textSelector("dark")]: { color: MENTION_COLORS.dark.fg },
46
58
  ".cm-mention.cm-mention-begin": {
47
59
  borderRadius: "2px 0 0 2px",
48
60
  padding: "1px 0 1px 4px",
package/dist/quote.js CHANGED
@@ -6,22 +6,27 @@ import { createRevealPlugin } from "./utils/reveal.js";
6
6
  // ---------------------------------------------------------------------------
7
7
  const hiddenMark = Decoration.replace({});
8
8
  const revealedMark = Decoration.mark({ class: "cm-formatting cm-formatting-quote" });
9
- /** 깊이별 줄 데코레이션 (깊이 제한이 없으므로 처음 쓸 때 만들어 재사용) */
9
+ /**
10
+ * 깊이별 줄 데코레이션 (깊이 제한이 없으므로 처음 쓸 때 만들어 재사용).
11
+ * top/bottom: 가장 바깥 인용의 첫 줄/마지막 줄 (세로줄 끝을 둥글게)
12
+ */
10
13
  const lineCache = new Map();
11
- function quoteLine(depth) {
12
- let deco = lineCache.get(depth);
14
+ function quoteLine(depth, top, bottom, indent) {
15
+ const key = `${depth}${top ? "t" : ""}${bottom ? "b" : ""}:${indent}`;
16
+ let deco = lineCache.get(key);
13
17
  if (!deco) {
14
18
  deco = Decoration.line({
15
- class: `cm-quote-line cm-quote-line-${depth}`,
19
+ class: `cm-quote-line cm-quote-line-${depth}${top ? " cm-quote-top" : ""}${bottom ? " cm-quote-bottom" : ""}`,
16
20
  // 테마에서 깊이만큼 왼쪽 세로줄을 그리는 데 씁니다.
17
- attributes: { style: `--cm-quote-depth: ${depth}` },
21
+ // indent: 목록·각주 안 인용처럼 ">" 앞에 들여쓰기가 있으면 세로줄을 그만큼 오른쪽(글 바로 앞)으로 옮깁니다.
22
+ attributes: { style: `--cm-quote-depth: ${depth}; --cm-quote-indent: ${indent}ch` },
18
23
  });
19
- lineCache.set(depth, deco);
24
+ lineCache.set(key, deco);
20
25
  }
21
26
  return deco;
22
27
  }
23
28
  /** 인용 밖에서는 인용을 담을 수 있는 컨테이너만 내려갑니다. */
24
- const CONTAINERS = new Set(["Document", "BulletList", "OrderedList", "ListItem"]);
29
+ const CONTAINERS = new Set(["Document", "BulletList", "OrderedList", "ListItem", "FootnoteDefinition"]);
25
30
  const Space = 32;
26
31
  // ---------------------------------------------------------------------------
27
32
  // 플러그인
@@ -42,6 +47,11 @@ export const quotePlugin = createRevealPlugin({
42
47
  const inSource = () => stack.length > 0 && stack[stack.length - 1].source;
43
48
  /** 줄 번호 → 그 줄을 덮는 인용의 최대 깊이 (가장 바깥 인용이 끝날 때 줄 데코레이션으로 변환) */
44
49
  const lineDepth = new Map();
50
+ /** 세로줄을 그리는 가장 바깥 인용의 첫 줄/마지막 줄 번호 */
51
+ let outerFirst = -1;
52
+ let outerLast = -1;
53
+ /** 가장 바깥 인용의 ">" 앞 들여쓰기 (칸) */
54
+ let outerIndent = 0;
45
55
  return {
46
56
  enter(node) {
47
57
  if (node.name === "Blockquote") {
@@ -59,6 +69,11 @@ export const quotePlugin = createRevealPlugin({
59
69
  const last = doc.lineAt(node.to).number;
60
70
  for (let n = first; n <= last; n++)
61
71
  lineDepth.set(n, depth);
72
+ if (depth === 1) {
73
+ outerFirst = first;
74
+ outerLast = last;
75
+ outerIndent = node.from - doc.lineAt(node.from).from;
76
+ }
62
77
  return;
63
78
  }
64
79
  if (node.name === "QuoteMark") {
@@ -87,29 +102,68 @@ export const quotePlugin = createRevealPlugin({
87
102
  // 가장 바깥 인용이 끝나면 줄마다 하나의 줄 데코레이션을 추가합니다.
88
103
  // (같은 줄에 style이 다른 줄 데코레이션이 여러 개면 하나만 남고 어느 쪽이 남을지는
89
104
  // 데코레이션 순서에 달려 있어 깊이가 틀릴 수 있으므로, 최대 깊이 하나만 넣습니다.)
90
- for (const [n, d] of lineDepth)
91
- decorations.push(quoteLine(d).range(doc.line(n).from));
105
+ for (const [n, d] of lineDepth) {
106
+ const line = doc.line(n);
107
+ // 줄마다 실제 들여쓰기(공백)만큼만 옮깁니다. (들여쓰지 않은 게으른 연속 줄은 옮기지 않음)
108
+ const lead = line.text.length - line.text.trimStart().length;
109
+ const indent = Math.min(outerIndent, lead);
110
+ decorations.push(quoteLine(d, n === outerFirst, n === outerLast, indent).range(line.from));
111
+ }
92
112
  lineDepth.clear();
113
+ outerFirst = outerLast = -1;
93
114
  },
94
115
  };
95
116
  },
96
117
  });
97
118
  // ---------------------------------------------------------------------------
98
- // 테마: 왼쪽 세로줄만 (깊이만큼 반복)
119
+ // 테마: 배경 없이 왼쪽 세로줄 + 차분한 글자색 (콜아웃은 색 배경 상자라 구분됨, 그림자 없음)
99
120
  // ---------------------------------------------------------------------------
100
121
  const BAR = "3px"; // 세로줄 두께
101
- const STEP = "14px"; // 깊이 한 단계의 폭
122
+ const STEP = "16px"; // 깊이 한 단계의 폭 (세로줄 + 간격)
123
+ const CAP = "3px"; // 둥근 끝 (세로줄 두께와 같은 지름)
124
+ /** 둥근 끝: 세로줄 두께만 한 원 (위/아래 끝 줄에서만 크기가 생김) */
125
+ const capImage = `radial-gradient(circle at 50% 50%, var(--cm-quote-color) 70%, transparent 72%)`;
102
126
  const quoteTheme = EditorView.baseTheme({
103
127
  ".cm-quote-line": {
104
- backgroundImage: `repeating-linear-gradient(to right, var(--cm-quote-color) 0 ${BAR}, transparent ${BAR} ${STEP})`,
105
- backgroundSize: `calc(var(--cm-quote-depth) * ${STEP}) 100%`,
128
+ "--cm-quote-cap-top": "0px",
129
+ "--cm-quote-cap-bottom": "0px",
130
+ // 세로줄 시작 위치: 감싼 컨테이너의 내용 시작(콜아웃 안이면 상자 안 글자 위치) + ">" 앞 들여쓰기
131
+ "--cm-quote-x": "calc(var(--cm-quote-base, 0px) + var(--cm-quote-indent, 0px))",
132
+ // 배경 그림 5장: 위 끝 원, 아래 끝 원, 가장 바깥 세로줄, 안쪽 인용들의 세로줄(깊이만큼 반복),
133
+ // 이 줄을 감싼 컨테이너의 레일(각주 정의 안이면 각주 레일. 배경 그림은 한 규칙만 적용되므로 함께 그림)
134
+ // 가장 바깥 세로줄은 끝 원의 가운데에서 시작/끝나도록 반지름만큼 줄입니다.
135
+ backgroundImage: [
136
+ capImage,
137
+ capImage,
138
+ "linear-gradient(var(--cm-quote-color), var(--cm-quote-color))",
139
+ `repeating-linear-gradient(to right, var(--cm-quote-color) 0 ${BAR}, transparent ${BAR} ${STEP})`,
140
+ "var(--cm-line-rail, linear-gradient(transparent, transparent))",
141
+ ].join(", "),
142
+ backgroundSize: [
143
+ `${BAR} var(--cm-quote-cap-top)`,
144
+ `${BAR} var(--cm-quote-cap-bottom)`,
145
+ // 컨테이너 아래 여백(콜아웃 끝 줄의 상자 안쪽 여백)에는 그리지 않습니다.
146
+ `${BAR} calc(100% - var(--cm-quote-cap-top) / 2 - var(--cm-quote-cap-bottom) / 2 - var(--cm-container-bottom, 0px))`,
147
+ `calc((var(--cm-quote-depth) - 1) * ${STEP}) calc(100% - var(--cm-container-bottom, 0px))`,
148
+ "var(--cm-line-rail-size, 0px 0px)",
149
+ ].join(", "),
150
+ backgroundPosition: [
151
+ "var(--cm-quote-x) 0",
152
+ "var(--cm-quote-x) calc(100% - var(--cm-container-bottom, 0px))",
153
+ "var(--cm-quote-x) calc(var(--cm-quote-cap-top) / 2)",
154
+ `calc(var(--cm-quote-x) + ${STEP}) 0`,
155
+ "var(--cm-line-rail-pos, 0 0)",
156
+ ].join(", "),
106
157
  backgroundRepeat: "no-repeat",
107
158
  // 내용 시작 위치를 다른 블록(코드 블록 등)에도 알려줍니다. (utils/container.ts)
108
- "--cm-container-indent": `calc(var(--cm-quote-depth) * ${STEP} + 2px)`,
159
+ "--cm-container-indent": `calc(var(--cm-quote-base, 0px) + var(--cm-quote-depth) * ${STEP} + 2px)`,
109
160
  paddingLeft: "var(--cm-container-indent)",
161
+ color: "var(--cm-quote-text)",
110
162
  },
111
- "&light .cm-quote-line": { "--cm-quote-color": "#d1d5db" },
112
- "&dark .cm-quote-line": { "--cm-quote-color": "#4b5563" },
163
+ ".cm-quote-top": { "--cm-quote-cap-top": CAP },
164
+ ".cm-quote-bottom": { "--cm-quote-cap-bottom": CAP },
165
+ "&light .cm-quote-line": { "--cm-quote-color": "#cbd5e1", "--cm-quote-text": "#475569" },
166
+ "&dark .cm-quote-line": { "--cm-quote-color": "#475569", "--cm-quote-text": "#a3adbb" },
113
167
  });
114
168
  /**
115
169
  * 인용(`> 내용`)을 하이브리드 방식으로 보여주는 확장입니다.
@@ -210,6 +210,7 @@ export const TABLE_CONTAINERS = new Set([
210
210
  "BulletList",
211
211
  "OrderedList",
212
212
  "ListItem",
213
+ "FootnoteDefinition",
213
214
  ]);
214
215
  /** 문서 전체의 표 (컨테이너만 훑음) */
215
216
  export function scanTables(state) {