@teriusu/rich-editor 0.9.1 → 0.9.2

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.
@@ -22,6 +22,7 @@
22
22
  ListChecks,
23
23
  Quote,
24
24
  ChevronDown,
25
+ Sigma,
25
26
  } from "lucide-svelte";
26
27
  import { cn } from "../utils/cn";
27
28
  import type { ToolbarFeature, PromptHandler } from "../types";
@@ -380,6 +381,28 @@
380
381
  </button>
381
382
  {/if}
382
383
 
384
+ {#if has('math')}
385
+ <!--
386
+ 버블 툴바는 **뭔가 선택돼 있을 때만** 뜨므로 여기서는 감싸기/풀기만 하면 된다
387
+ (고정 툴바처럼 "빈 선택이면 새로 넣기" 로 떨어질 일이 없다).
388
+ `minimal` 프리셋에는 고정 툴바가 없어서, 댓글에서 수식을 만드는 유일한 버튼이 이것이다.
389
+ -->
390
+ <button
391
+ type="button"
392
+ onclick={() => editor.chain().focus().toggleMathInline().run()}
393
+ title="인라인 수식"
394
+ aria-label="인라인 수식"
395
+ class={cn(
396
+ "p-1.5 rounded-full transition-colors",
397
+ isActive("math_inline")
398
+ ? "bg-white/20 text-white"
399
+ : "text-white/70 hover:text-white hover:bg-white/10",
400
+ )}
401
+ >
402
+ <Sigma size={iconSize} />
403
+ </button>
404
+ {/if}
405
+
383
406
  {#if has('highlight') || has('text-color')}
384
407
  <div class="w-px h-5 bg-white/20 mx-0.5"></div>
385
408
  {/if}
@@ -1 +1 @@
1
- {"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAuB3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AAoXJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAwB3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AAgYJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -34,6 +34,7 @@
34
34
  Plus,
35
35
  Pilcrow,
36
36
  Palette,
37
+ Sigma,
37
38
  } from "lucide-svelte";
38
39
  import { cn } from "../utils/cn";
39
40
  import InputModal from "./InputModal.svelte";
@@ -249,7 +250,8 @@
249
250
  has("columns-2") ||
250
251
  has("columns-3") ||
251
252
  has("horizontal-rule") ||
252
- has("code-block"),
253
+ has("code-block") ||
254
+ has("math"),
253
255
  );
254
256
 
255
257
  const hasBlockItems = $derived(
@@ -533,6 +535,37 @@
533
535
  <Strikethrough size={iconSize} />
534
536
  </button>
535
537
  {/if}
538
+ {#if has('math')}
539
+ <!--
540
+ 선택한 텍스트를 인라인 수식으로 감싸고, 이미 수식인 자리에서 누르면 원문으로 되돌린다
541
+ (구세대 `setMath.js` 와 같은 토글). 선택이 없으면 감쌀 게 없으니 프롬프트로 새로 넣는다.
542
+
543
+ ⚠️ **`run()` 의 반환값으로 갈라지면 안 된다.** `chain().focus().toggleMathInline().run()`
544
+ 은 감싸기가 실제로 성공해도 false 를 돌려주는 경우가 있어(체인의 다른 단계가 false 를
545
+ 섞는다), 감싸 놓고 프롬프트까지 같이 여는 걸 브라우저에서 확인했다.
546
+ **무엇을 할지는 실행 전에 상태로 정한다.**
547
+ -->
548
+ <button
549
+ type="button"
550
+ onclick={() => {
551
+ if (editor.state.selection.empty && !editor.isActive('math_inline')) {
552
+ editor.chain().focus().promptMathInline().run();
553
+ } else {
554
+ editor.chain().focus().toggleMathInline().run();
555
+ }
556
+ }}
557
+ data-tooltip="인라인 수식"
558
+ aria-label="인라인 수식"
559
+ class={cn(
560
+ "p-1.5 rounded-md transition-colors",
561
+ isActive("math_inline")
562
+ ? "hce-active"
563
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
564
+ )}
565
+ >
566
+ <Sigma size={iconSize} />
567
+ </button>
568
+ {/if}
536
569
  {#if has('text-color')}
537
570
  <div bind:this={colorMenuEl} class="relative">
538
571
  <button
@@ -687,6 +720,16 @@
687
720
  <Code2 size={14} /> 코드 블록
688
721
  </button>
689
722
  {/if}
723
+ {#if has('math')}
724
+ <button
725
+ type="button"
726
+ class="w-full text-left px-2.5 py-1.5 text-xs transition-colors flex items-center gap-2 hover:bg-muted"
727
+ onclick={() =>
728
+ runInsert(() => editor.chain().focus().promptMathDisplay().run())}
729
+ >
730
+ <Sigma size={14} /> 수식 블록
731
+ </button>
732
+ {/if}
690
733
  {#if has('pdf')}
691
734
  <button
692
735
  type="button"
@@ -1 +1 @@
1
- {"version":3,"file":"FixedToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/FixedToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAqCtC,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AA6lBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"FixedToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/FixedToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAsCtC,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAqnBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,165 @@
1
+ <script lang="ts">
2
+ /**
3
+ * LaTeX 수식 편집 모달.
4
+ *
5
+ * `InputModal` 을 재사용하지 않고 따로 둔 이유는 둘이다:
6
+ * 1. 수식은 한 줄로 안 끝난다(행렬·정렬 환경) → `textarea`
7
+ * 2. LaTeX 는 눈으로 확인하지 않으면 맞게 썼는지 알 수 없다 → **실시간 미리보기**
8
+ *
9
+ * 렌더는 편집기 NodeView 와 같은 KaTeX 옵션(`throwOnError:false`)을 쓴다. 그래서
10
+ * 여기서 보이는 모양이 곧 본문에 들어갈 모양이다.
11
+ */
12
+ import { X } from "lucide-svelte";
13
+ import katex from "katex";
14
+
15
+ let {
16
+ latex = "",
17
+ displayMode = false,
18
+ onConfirm,
19
+ onCancel,
20
+ }: {
21
+ latex?: string;
22
+ displayMode?: boolean;
23
+ /** 빈 문자열로 확인하면 호출부가 노드를 지운다(편집 중일 때). */
24
+ onConfirm: (value: string) => void;
25
+ onCancel: () => void;
26
+ } = $props();
27
+
28
+ let value = $state(latex);
29
+ let inputEl: HTMLTextAreaElement | undefined = $state();
30
+
31
+ const preview = $derived.by(() => {
32
+ const source = value.trim();
33
+ if (!source) return { html: "", error: "" };
34
+ try {
35
+ return {
36
+ html: katex.renderToString(source, {
37
+ displayMode,
38
+ throwOnError: true,
39
+ strict: false,
40
+ output: "html",
41
+ }),
42
+ error: "",
43
+ };
44
+ } catch (err) {
45
+ return { html: "", error: err instanceof Error ? err.message : String(err) };
46
+ }
47
+ });
48
+
49
+ $effect(() => {
50
+ if (inputEl) {
51
+ inputEl.focus();
52
+ inputEl.select();
53
+ }
54
+ });
55
+
56
+ $effect(() => {
57
+ function onKeydown(e: KeyboardEvent) {
58
+ if (e.key === "Escape") {
59
+ e.preventDefault();
60
+ onCancel();
61
+ }
62
+ }
63
+ window.addEventListener("keydown", onKeydown);
64
+ return () => window.removeEventListener("keydown", onKeydown);
65
+ });
66
+
67
+ function handleSubmit() {
68
+ onConfirm(value.trim());
69
+ }
70
+
71
+ function handleKeydown(e: KeyboardEvent) {
72
+ // 줄바꿈이 필요한 문법(행렬 등)이 있으므로 Enter 는 그대로 두고,
73
+ // 확인은 Ctrl/⌘+Enter 로 받는다.
74
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
75
+ e.preventDefault();
76
+ handleSubmit();
77
+ }
78
+ }
79
+ </script>
80
+
81
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
82
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
83
+ <!--
84
+ ⚠️ z-index 를 Tailwind `z-50` 으로 두면 **호스트 앱 밑에 깔린다.** 정올은 헤더가 98,
85
+ 레이아웃 오버레이가 1000, 다이얼로그 스크림이 99999999 다(`apps/jungol/src/routes/layout.css`).
86
+ 이 모달은 본문 클릭으로도 열리므로 다이얼로그 안의 에디터에서 열릴 수 있다 — 스크림보다 위여야 한다.
87
+ Tailwind 임의값 클래스 대신 인라인 style 로 준다(`.claude/rules/rich-editor.md`: 동적 클래스 금지).
88
+ -->
89
+ <div
90
+ class="fixed inset-0 flex items-center justify-center bg-black/40"
91
+ style="z-index: 100000001"
92
+ onclick={onCancel}
93
+ >
94
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
95
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
96
+ <div
97
+ class="bg-background border border-border rounded-xl shadow-xl p-5 w-[440px] max-w-[90vw]"
98
+ onclick={(e) => e.stopPropagation()}
99
+ >
100
+ <div class="flex items-center justify-between mb-3">
101
+ <span class="text-sm font-semibold">
102
+ {displayMode ? "수식 블록" : "인라인 수식"}
103
+ </span>
104
+ <button
105
+ type="button"
106
+ class="p-1 rounded-md hover:bg-muted text-muted-foreground"
107
+ onclick={onCancel}
108
+ aria-label="닫기"
109
+ >
110
+ <X size={14} />
111
+ </button>
112
+ </div>
113
+
114
+ <textarea
115
+ bind:this={inputEl}
116
+ rows="3"
117
+ spellcheck="false"
118
+ class="w-full border border-border rounded-md px-3 py-2 text-sm font-mono bg-background resize-y focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary"
119
+ placeholder={"\\frac{a}{b} \\le c"}
120
+ bind:value
121
+ onkeydown={handleKeydown}
122
+ ></textarea>
123
+
124
+ <!--
125
+ ⚠️ 바탕을 `bg-muted/40` 으로 주지 않는다. 호스트가 `--muted` 를 **hex** 로 주는데
126
+ Tailwind 3 의 투명도 변형은 채널 표기를 요구해 그 클래스를 **조용히 만들지 않는다**
127
+ (빌드된 CSS 에 규칙이 아예 없다 — 실제로 확인했다). `.claude/rules/rich-editor.md` 대로
128
+ `color-mix` 를 인라인으로 준다. `editor.css` 의 `.hce-active` 가 같은 이유로 같은 처방을 쓴다.
129
+ -->
130
+ <div
131
+ class="mt-3 min-h-[52px] flex items-center justify-center rounded-md border border-border px-3 py-2 overflow-x-auto"
132
+ style="background-color: color-mix(in srgb, var(--muted) 40%, transparent)"
133
+ >
134
+ {#if preview.error}
135
+ <span class="text-xs text-destructive break-all">{preview.error}</span>
136
+ {:else if preview.html}
137
+ <!-- katex.renderToString 산출물. 사용자 입력은 KaTeX 가 이스케이프한다. -->
138
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
139
+ {@html preview.html}
140
+ {:else}
141
+ <span class="text-xs text-muted-foreground">미리보기</span>
142
+ {/if}
143
+ </div>
144
+
145
+ <div class="flex items-center justify-between gap-2 mt-4">
146
+ <span class="text-[11px] text-muted-foreground">⌘/Ctrl + Enter 로 확인</span>
147
+ <div class="flex gap-2">
148
+ <button
149
+ type="button"
150
+ class="px-3.5 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors"
151
+ onclick={onCancel}
152
+ >
153
+ 취소
154
+ </button>
155
+ <button
156
+ type="button"
157
+ class="px-3.5 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
158
+ onclick={handleSubmit}
159
+ >
160
+ 확인
161
+ </button>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
@@ -0,0 +1,11 @@
1
+ type $$ComponentProps = {
2
+ latex?: string;
3
+ displayMode?: boolean;
4
+ /** 빈 문자열로 확인하면 호출부가 노드를 지운다(편집 중일 때). */
5
+ onConfirm: (value: string) => void;
6
+ onCancel: () => void;
7
+ };
8
+ declare const MathModal: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type MathModal = ReturnType<typeof MathModal>;
10
+ export default MathModal;
11
+ //# sourceMappingURL=MathModal.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MathModal.svelte.d.ts","sourceRoot":"","sources":["../../src/components/MathModal.svelte.ts"],"names":[],"mappings":"AAgBC,KAAK,gBAAgB,GAAI;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,yCAAyC;IACzC,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB,CAAC;AAiHJ,QAAA,MAAM,SAAS,sDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -21,6 +21,7 @@
21
21
  Type,
22
22
  Tv,
23
23
  SquareDashed,
24
+ Sigma,
24
25
  } from "lucide-svelte";
25
26
  import type { SlashMenuItem, ToolbarFeature, PromptHandler } from "../types";
26
27
  import type { Component } from "svelte";
@@ -30,6 +31,7 @@
30
31
  /** 각 feature가 속하는 섹션 라벨 */
31
32
  const SECTION_MAP: Record<string, string> = {
32
33
  "code-block": "자주 쓰는",
34
+ math: "자주 쓰는",
33
35
  file: "미디어",
34
36
  pdf: "미디어",
35
37
  paragraph: "기본",
@@ -133,6 +135,15 @@
133
135
  command: (editor) =>
134
136
  editor.chain().focus().setCodeBlock().run(),
135
137
  },
138
+ {
139
+ feature: "math",
140
+ label: "수식",
141
+ keywords: "math latex tex 수식 공식 수학 katex 시그마 분수",
142
+ icon: Sigma,
143
+ // 슬래시 메뉴는 `/수식` 을 지우면서 커맨드를 부른다. 프롬프트는 그 뒤에 열려야
144
+ // 지운 자리에 결과가 들어간다 — `promptMathDisplay` 가 비동기라 순서가 맞는다.
145
+ command: (editor) => editor.chain().focus().promptMathDisplay().run(),
146
+ },
136
147
  {
137
148
  feature: "toggle",
138
149
  label: "토글",
@@ -1 +1 @@
1
- {"version":3,"file":"SlashCommandMenu.svelte.d.ts","sourceRoot":"","sources":["../../src/components/SlashCommandMenu.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAuB3C,OAAO,KAAK,EAAiB,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC7E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAEvC,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AA2WJ,QAAA,MAAM,gBAAgB,qCAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
1
+ {"version":3,"file":"SlashCommandMenu.svelte.d.ts","sourceRoot":"","sources":["../../src/components/SlashCommandMenu.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAwB3C,OAAO,KAAK,EAAiB,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC7E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAEvC,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AAqXJ,QAAA,MAAM,gBAAgB,qCAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
@@ -38,11 +38,12 @@
38
38
  import { FileAttachment } from "../extensions/FileAttachment";
39
39
  import { MbusVideo } from "../extensions/MbusVideo";
40
40
  import { CardBlock } from "../extensions/CardBlock";
41
- import { MathInline, MathDisplay } from "../extensions/Math";
41
+ import { MathInline, MathDisplay, type MathPrompt } from "../extensions/Math";
42
42
  import FixedToolbar from "./FixedToolbar.svelte";
43
43
  import BubbleToolbar from "./BubbleToolbar.svelte";
44
44
  import SlashCommandMenu from "./SlashCommandMenu.svelte";
45
45
  import TableBubbleMenu from "./TableBubbleMenu.svelte";
46
+ import MathModal from "./MathModal.svelte";
46
47
  import type { UploadHandler, PromptHandler, ToolbarMode, ToolbarFeature } from "../types";
47
48
  import { resolveFeatures } from "../types";
48
49
  import type { FileResolver } from "../extensions/FileAttachment";
@@ -120,6 +121,7 @@
120
121
  onPromptImage,
121
122
  onPromptMbus,
122
123
  onPromptCardBackground,
124
+ onPromptMath,
123
125
  toolbarEnd,
124
126
  extensions: extraExtensions = [],
125
127
  editable = true,
@@ -137,6 +139,8 @@
137
139
  onPromptMbus?: PromptHandler;
138
140
  /** 카드 배경 고르기. 미제공 시 window.prompt 폴백 */
139
141
  onPromptCardBackground?: PromptHandler;
142
+ /** LaTeX 수식 편집. 미제공 시 내장 MathModal(실시간 미리보기) 폴백 */
143
+ onPromptMath?: MathPrompt;
140
144
  /** 고정 툴바 오른쪽 끝에 끼워 넣을 조각 */
141
145
  toolbarEnd?: Snippet;
142
146
  extensions?: AnyExtension[];
@@ -161,6 +165,36 @@
161
165
  const cc = editor?.storage?.characterCount;
162
166
  return { chars: cc?.characters?.() ?? 0, words: cc?.words?.() ?? 0 };
163
167
  });
168
+ /*
169
+ * 수식 프롬프트 다리.
170
+ *
171
+ * 확장(vanilla NodeView·입력 규칙)은 Svelte 를 모르므로 **약속된 함수 하나**만 받는다.
172
+ * 그 함수가 여기서 `MathModal` 을 띄우고 사용자가 확인/취소할 때 resolve 한다.
173
+ * 링크·이미지가 `InputModal` 로 하는 것과 같은 구조인데, 저쪽은 툴바가 열고 이쪽은
174
+ * 본문 클릭·입력 규칙·슬래시가 열기 때문에 상태를 에디터 최상단에 둬야 한다.
175
+ *
176
+ * ⚠️ 이 함수는 확장을 만들 때 한 번만 캡처된다. 그래서 `onPromptMath` 를 직접 넘기지 않고
177
+ * 호출 시점에 읽는다 — 호스트가 나중에 핸들러를 붙여도 따라간다.
178
+ */
179
+ let mathPrompt = $state<{
180
+ latex: string;
181
+ displayMode: boolean;
182
+ resolve: (value: string | null) => void;
183
+ } | null>(null);
184
+
185
+ const promptMath: MathPrompt = (latex, displayMode) => {
186
+ if (onPromptMath) return onPromptMath(latex, displayMode);
187
+ return new Promise<string | null>((resolve) => {
188
+ mathPrompt = { latex, displayMode, resolve };
189
+ });
190
+ };
191
+
192
+ function closeMathPrompt(value: string | null) {
193
+ const pending = mathPrompt;
194
+ mathPrompt = null;
195
+ pending?.resolve(value);
196
+ }
197
+
164
198
  let uploading = $state(false);
165
199
  let pdfInputEl: HTMLInputElement | undefined = $state();
166
200
  let fileInputEl: HTMLInputElement | undefined = $state();
@@ -413,10 +447,10 @@
413
447
  */
414
448
  ...(extraExtensions.some((ext) => (ext as any).name === "math_inline")
415
449
  ? []
416
- : [MathInline]),
450
+ : [MathInline.configure({ promptMath })]),
417
451
  ...(extraExtensions.some((ext) => (ext as any).name === "math_display")
418
452
  ? []
419
- : [MathDisplay]),
453
+ : [MathDisplay.configure({ promptMath })]),
420
454
  Columns,
421
455
  Column,
422
456
  CodeBlockTopEscape,
@@ -660,6 +694,20 @@
660
694
  </div>
661
695
  {/if}
662
696
 
697
+ <!--
698
+ 수식 프롬프트. `onPromptMath` 를 준 호스트에겐 열리지 않는다(그쪽이 직접 띄운다).
699
+ `features` 로 막지 않는다 — `$…$` 입력 규칙과 붙여넣기 변환은 feature 와 무관하게
700
+ 항상 살아 있어서, 그렇게 만든 수식을 고치려면 이 모달이 필요하다.
701
+ -->
702
+ {#if mathPrompt}
703
+ <MathModal
704
+ latex={mathPrompt.latex}
705
+ displayMode={mathPrompt.displayMode}
706
+ onConfirm={(value) => closeMathPrompt(value)}
707
+ onCancel={() => closeMathPrompt(null)}
708
+ />
709
+ {/if}
710
+
663
711
  {#if features.has('character-count')}
664
712
  <div
665
713
  class="flex justify-end px-4 py-2 text-xs text-muted-foreground border-t border-border"
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from "svelte";
2
2
  import { type AnyExtension } from "@tiptap/core";
3
+ import { type MathPrompt } from "../extensions/Math";
3
4
  import type { UploadHandler, PromptHandler, ToolbarMode, ToolbarFeature } from "../types";
4
5
  import type { FileResolver } from "../extensions/FileAttachment";
5
6
  type $$ComponentProps = {
@@ -14,6 +15,8 @@ type $$ComponentProps = {
14
15
  onPromptMbus?: PromptHandler;
15
16
  /** 카드 배경 고르기. 미제공 시 window.prompt 폴백 */
16
17
  onPromptCardBackground?: PromptHandler;
18
+ /** LaTeX 수식 편집. 미제공 시 내장 MathModal(실시간 미리보기) 폴백 */
19
+ onPromptMath?: MathPrompt;
17
20
  /** 고정 툴바 오른쪽 끝에 끼워 넣을 조각 */
18
21
  toolbarEnd?: Snippet;
19
22
  extensions?: AnyExtension[];
@@ -1 +1 @@
1
- {"version":3,"file":"TipTapEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/components/TipTapEditor.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AA6BtC,OAAO,EAAa,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAe5D,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE1F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAEhE,KAAK,gBAAgB,GAAI;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,wCAAwC;IACxC,sBAAsB,CAAC,EAAE,aAAa,CAAC;IACvC,4BAA4B;IAC5B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;CAC7B,CAAC;AA8nBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"TipTapEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/components/TipTapEditor.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AA6BtC,OAAO,EAAa,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAU5D,OAAO,EAA2B,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAM9E,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAE1F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAEhE,KAAK,gBAAgB,GAAI;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,wCAAwC;IACxC,sBAAsB,CAAC,EAAE,aAAa,CAAC;IACvC,mDAAmD;IACnD,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,4BAA4B;IAC5B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;CAC7B,CAAC;AAmqBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -7,22 +7,52 @@
7
7
  *
8
8
  * 노드는 atom이며 텍스트 콘텐츠(LaTeX 원문)를 자식 텍스트 노드로 보관한다.
9
9
  * NodeView가 KaTeX로 렌더링한다.
10
+ *
11
+ * ## 입력 경로 — 구세대(@seorii/tiptap)와 맞춘 것
12
+ *
13
+ * 이전할 때 **렌더만 옮기고 넣는 길이 통째로 빠져 있었다.** 문서는 열리는데 새 수식을
14
+ * 만들 방법이 없었다는 뜻이다. 구세대가 갖고 있던 네 경로를 여기로 되살렸다:
15
+ *
16
+ * | 경로 | 구세대 | 여기 |
17
+ * |---|---|---|
18
+ * | `$…$` | `makeInlineMathInputRule(/\$(.+)\$/)` | `addInputRules` (MathInline) |
19
+ * | `$$…$$` / `$$`+스페이스 | `makeBlockMathInputRule(/\$\$\s+$/)` | `addInputRules` (MathDisplay) |
20
+ * | 붙여넣기 `$…$` 자동 변환 | `plugin/mathPaste.js` | `mathPastePlugin` (아래) |
21
+ * | 선택 영역 감싸기/풀기 | `tiptap/setMath.js` | `toggleMathInline` 커맨드 |
22
+ *
23
+ * ## 편집은 모달로 — 구세대와 다른 유일한 지점
24
+ *
25
+ * 구세대는 노드 안에 **중첩 EditorView**를 띄워 LaTeX 원문을 그 자리에서 고치게 했다
26
+ * (`MathView` + `mathPlugin`/`mathSelectPlugin`, vanilla ProseMirror 400줄 남짓).
27
+ * 여기서는 그 대신 **호스트가 주는 프롬프트 훅**을 부른다 — 링크·이미지·카드 배경이
28
+ * 이미 쓰는 규약(`onPromptLink`, `promptBackground`)과 같은 모양이고, ProseMirror
29
+ * 내부(중첩 뷰·커서 브리지·backspace 체인)에 손대지 않아 유지비가 훨씬 싸다.
30
+ *
31
+ * 호스트가 `promptMath`를 안 주면 `window.prompt`로 떨어진다.
10
32
  */
11
33
  import { Node } from "@tiptap/core";
12
- interface MathOptions {
34
+ /** 현재 LaTeX 원문을 받아 새 값(취소면 null)을 돌려주는 호스트 훅. */
35
+ export type MathPrompt = (latex: string, displayMode: boolean) => Promise<string | null>;
36
+ export interface MathOptions {
13
37
  HTMLAttributes: Record<string, unknown>;
38
+ promptMath: MathPrompt | null;
14
39
  }
15
40
  declare module "@tiptap/core" {
16
41
  interface Commands<ReturnType> {
17
42
  mathInline: {
18
43
  insertMathInline: (latex: string) => ReturnType;
44
+ /** 선택 영역을 인라인 수식으로 감싼다. 이미 수식이면 원문 텍스트로 되돌린다. */
45
+ toggleMathInline: () => ReturnType;
46
+ /** 프롬프트를 띄워 인라인 수식을 새로 넣는다. */
47
+ promptMathInline: () => ReturnType;
19
48
  };
20
49
  mathDisplay: {
21
50
  insertMathDisplay: (latex: string) => ReturnType;
51
+ /** 프롬프트를 띄워 수식 블록을 새로 넣는다. */
52
+ promptMathDisplay: () => ReturnType;
22
53
  };
23
54
  }
24
55
  }
25
56
  export declare const MathInline: Node<MathOptions, any>;
26
57
  export declare const MathDisplay: Node<MathOptions, any>;
27
- export {};
28
58
  //# sourceMappingURL=Math.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Math.d.ts","sourceRoot":"","sources":["../../src/extensions/Math.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAmB,IAAI,EAAE,MAAM,cAAc,CAAC;AAGrD,UAAU,WAAW;IACnB,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC;AAED,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,UAAU,EAAE;YACV,gBAAgB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,UAAU,CAAC;SACjD,CAAC;QACF,WAAW,EAAE;YACX,iBAAiB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,UAAU,CAAC;SAClD,CAAC;KACH;CACF;AA2BD,eAAO,MAAM,UAAU,wBAsCrB,CAAC;AAEH,eAAO,MAAM,WAAW,wBAuCtB,CAAC"}
1
+ {"version":3,"file":"Math.d.ts","sourceRoot":"","sources":["../../src/extensions/Math.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAa,IAAI,EAAgC,MAAM,cAAc,CAAC;AAK7E,iDAAiD;AACjD,MAAM,MAAM,UAAU,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAEzF,MAAM,WAAW,WAAW;IAC1B,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;CAC/B;AAED,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,UAAU,EAAE;YACV,gBAAgB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,UAAU,CAAC;YAChD,iDAAiD;YACjD,gBAAgB,EAAE,MAAM,UAAU,CAAC;YACnC,+BAA+B;YAC/B,gBAAgB,EAAE,MAAM,UAAU,CAAC;SACpC,CAAC;QACF,WAAW,EAAE;YACX,iBAAiB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,UAAU,CAAC;YACjD,8BAA8B;YAC9B,iBAAiB,EAAE,MAAM,UAAU,CAAC;SACrC,CAAC;KACH;CACF;AAkZD,eAAO,MAAM,UAAU,wBA4GrB,CAAC;AAEH,eAAO,MAAM,WAAW,wBA8EtB,CAAC"}