@tnotesjs/ui 0.2.1 → 0.3.1

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.
@@ -0,0 +1,672 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, ref, watch } from 'vue'
3
+ import { replaceInlineRange } from '@tnotesjs/mindmap-core'
4
+ import type {
5
+ InlineFormat,
6
+ MindmapNode,
7
+ MindmapSession,
8
+ RichInlineEditorElement
9
+ } from '@tnotesjs/mindmap-core'
10
+ import AppIcon from './AppIcon.vue'
11
+ import RichInlineEditor from './RichInlineEditor.vue'
12
+
13
+ const props = defineProps<{
14
+ session: MindmapSession
15
+ visible: boolean
16
+ version: number
17
+ onJump: (id: string) => void
18
+ }>()
19
+
20
+ const emit = defineEmits<{ close: [] }>()
21
+ const query = ref('')
22
+ const replacement = ref('')
23
+ const activeIndex = ref(0)
24
+ const inputRef = ref<HTMLInputElement>()
25
+ const replaceInputRef = ref<HTMLInputElement>()
26
+ const replaceNotice = ref('')
27
+ const EMPTY_MATCHES: MindmapNode[] = []
28
+ const hasQuery = computed(() => query.value.trim().length > 0)
29
+
30
+ const matches = computed<MindmapNode[]>(() => {
31
+ if (props.version < 0 || !hasQuery.value) return EMPTY_MATCHES
32
+ return props.session.search(query.value)
33
+ })
34
+
35
+ watch(query, () => {
36
+ activeIndex.value = 0
37
+ replaceNotice.value = ''
38
+ })
39
+
40
+ watch(matches, () => {
41
+ activeIndex.value = Math.min(activeIndex.value, Math.max(0, matches.value.length - 1))
42
+ props.session.setMatchHighlight(new Set(matches.value.map((node) => node.id)))
43
+ })
44
+
45
+ watch(
46
+ () => props.visible,
47
+ (visible) => {
48
+ if (visible) nextTick(() => inputRef.value?.focus())
49
+ else {
50
+ query.value = ''
51
+ replacement.value = ''
52
+ replaceNotice.value = ''
53
+ props.session.setMatchHighlight(new Set())
54
+ }
55
+ }
56
+ )
57
+
58
+ function pathFor(node: MindmapNode): string {
59
+ const path: string[] = []
60
+ let current = node.parent
61
+ while (current) {
62
+ path.unshift(current.content.text)
63
+ current = current.parent
64
+ }
65
+ return path.join(' / ')
66
+ }
67
+
68
+ function commitResult(node: MindmapNode, payload: { raw: string; text: string }) {
69
+ if (node.content.image) props.session.updateNodeDisplayText(node.id, payload.text)
70
+ else if (payload.raw !== node.content.raw) props.session.updateNodeRaw(node.id, payload.raw)
71
+ }
72
+
73
+ function inlineFormatShortcut(event: KeyboardEvent, hasSelection = true): InlineFormat | null {
74
+ const key = event.key.toLowerCase()
75
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return null
76
+ if (key === 'b' && !event.shiftKey) return 'bold'
77
+ if (key === 'i' && !event.shiftKey) return 'italic'
78
+ if (key === 'u' && !event.shiftKey) return 'underline'
79
+ if (key === 'enter' && !event.shiftKey && hasSelection) return 'strike'
80
+ if ((key === 's' || key === 'x') && event.shiftKey) return 'strike'
81
+ if (key === 'h' && event.shiftKey) return 'highlight'
82
+ if (key === 'e' && !event.shiftKey) return 'code'
83
+ return null
84
+ }
85
+
86
+ function onResultKeydown(node: MindmapNode, event: KeyboardEvent) {
87
+ event.stopPropagation()
88
+ const editor = event.currentTarget as RichInlineEditorElement
89
+ if (event.isComposing || editor.isComposing) return
90
+ const mod = event.metaKey || event.ctrlKey
91
+ const key = event.key.toLowerCase()
92
+ if (mod && key === '\\' && !node.content.image && editor.value.length > 0) {
93
+ event.preventDefault()
94
+ const caretStart = editor.selectionStart
95
+ const caretEnd = editor.selectionEnd
96
+ const collapsed = caretStart === caretEnd
97
+ const start = collapsed ? 0 : caretStart
98
+ const end = collapsed ? editor.value.length : caretEnd
99
+ commitResult(node, { raw: editor.rawValue, text: editor.value })
100
+ editor.markCommitted()
101
+ props.session.clearNodeInlineFormats(node.id, start, end)
102
+ nextTick(() => {
103
+ editor.focus()
104
+ editor.setSelectionRange(caretStart, collapsed ? caretStart : caretEnd)
105
+ })
106
+ return
107
+ }
108
+ const format = node.content.image
109
+ ? null
110
+ : inlineFormatShortcut(event, editor.selectionStart !== editor.selectionEnd)
111
+ if (format && editor.value.length > 0) {
112
+ event.preventDefault()
113
+ const caretStart = editor.selectionStart
114
+ const caretEnd = editor.selectionEnd
115
+ const collapsed = caretStart === caretEnd
116
+ const start = collapsed ? 0 : caretStart
117
+ const end = collapsed ? editor.value.length : caretEnd
118
+ commitResult(node, { raw: editor.rawValue, text: editor.value })
119
+ editor.markCommitted()
120
+ props.session.toggleNodeInlineFormat(node.id, start, end, format)
121
+ nextTick(() => {
122
+ editor.focus()
123
+ editor.setSelectionRange(caretStart, collapsed ? caretStart : caretEnd)
124
+ })
125
+ return
126
+ }
127
+ if (mod && event.shiftKey && key === 'l' && node !== props.session.document.root) {
128
+ event.preventDefault()
129
+ commitResult(node, { raw: editor.rawValue, text: editor.value })
130
+ editor.markCommitted()
131
+ props.session.toggleTask(node.id)
132
+ } else if (mod && key === 'f') {
133
+ event.preventDefault()
134
+ inputRef.value?.focus()
135
+ } else if (event.key === 'Enter') {
136
+ event.preventDefault()
137
+ editor.blur()
138
+ } else if (event.key === 'Escape') {
139
+ event.preventDefault()
140
+ editor.blur()
141
+ emit('close')
142
+ }
143
+ }
144
+
145
+ function jump(index: number) {
146
+ const list = matches.value
147
+ if (list.length === 0) return
148
+ activeIndex.value = (index + list.length) % list.length
149
+ props.onJump(list[activeIndex.value].id)
150
+ }
151
+
152
+ function matchingRanges(text: string, needle: string): Array<{ start: number; end: number }> {
153
+ const normalizedNeedle = needle.trim().toLocaleLowerCase()
154
+ if (!normalizedNeedle) return []
155
+ const normalizedText = text.toLocaleLowerCase()
156
+ const ranges: Array<{ start: number; end: number }> = []
157
+ let offset = 0
158
+ while (offset <= normalizedText.length - normalizedNeedle.length) {
159
+ const start = normalizedText.indexOf(normalizedNeedle, offset)
160
+ if (start < 0) break
161
+ ranges.push({ start, end: start + normalizedNeedle.length })
162
+ offset = start + Math.max(1, normalizedNeedle.length)
163
+ }
164
+ return ranges
165
+ }
166
+
167
+ function replacedNodeValue(
168
+ node: MindmapNode,
169
+ replaceEveryMatch: boolean
170
+ ): { raw: string; text: string; count: number } {
171
+ const ranges = matchingRanges(node.content.text, query.value)
172
+ const selectedRanges = replaceEveryMatch ? ranges : ranges.slice(0, 1)
173
+ if (selectedRanges.length === 0) {
174
+ return { raw: node.content.raw, text: node.content.text, count: 0 }
175
+ }
176
+ if (node.content.image) {
177
+ let text = node.content.text
178
+ for (const range of [...selectedRanges].reverse()) {
179
+ text = text.slice(0, range.start) + replacement.value + text.slice(range.end)
180
+ }
181
+ return { raw: node.content.raw, text, count: selectedRanges.length }
182
+ }
183
+ let raw = node.content.raw
184
+ for (const range of [...selectedRanges].reverse()) {
185
+ raw = replaceInlineRange(raw, range.start, range.end, replacement.value)
186
+ }
187
+ return { raw, text: node.content.text, count: selectedRanges.length }
188
+ }
189
+
190
+ function replaceCurrent() {
191
+ const node = matches.value[activeIndex.value]
192
+ if (!node) return
193
+ const next = replacedNodeValue(node, false)
194
+ if (next.count === 0) return
195
+ if (node.content.image) props.session.updateNodeDisplayText(node.id, next.text)
196
+ else props.session.updateNodeRaw(node.id, next.raw)
197
+ replaceNotice.value = '已替换 1 处'
198
+ nextTick(() => {
199
+ if (matches.value.length > 0) {
200
+ activeIndex.value = Math.min(activeIndex.value, matches.value.length - 1)
201
+ props.onJump(matches.value[activeIndex.value].id)
202
+ }
203
+ })
204
+ }
205
+
206
+ function replaceAll() {
207
+ const operations = matches.value
208
+ .map((node) => ({ id: node.id, image: !!node.content.image, ...replacedNodeValue(node, true) }))
209
+ .filter((operation) => operation.count > 0)
210
+ const count = operations.reduce((total, operation) => total + operation.count, 0)
211
+ if (count === 0) return
212
+ props.session.transact((doc) => {
213
+ for (const operation of operations) {
214
+ const node = doc.find(operation.id)
215
+ if (!node) continue
216
+ if (operation.image) doc.updateDisplayText(node, operation.text)
217
+ else doc.updateRaw(node, operation.raw)
218
+ }
219
+ })
220
+ replaceNotice.value = `已替换 ${count} 处`
221
+ nextTick(() => replaceInputRef.value?.focus())
222
+ }
223
+
224
+ function onSearchKeydown(event: KeyboardEvent) {
225
+ event.stopPropagation()
226
+ if (event.key === 'Enter') {
227
+ event.preventDefault()
228
+ jump(activeIndex.value + (event.shiftKey ? -1 : 1))
229
+ } else if (event.key === 'Escape') {
230
+ event.preventDefault()
231
+ emit('close')
232
+ }
233
+ }
234
+
235
+ function onReplaceKeydown(event: KeyboardEvent) {
236
+ event.stopPropagation()
237
+ if (event.key === 'Enter') {
238
+ event.preventDefault()
239
+ replaceCurrent()
240
+ } else if (event.key === 'Escape') {
241
+ event.preventDefault()
242
+ emit('close')
243
+ }
244
+ }
245
+
246
+ function reveal(node: MindmapNode) {
247
+ props.session.expandAncestors(node.id)
248
+ props.onJump(node.id)
249
+ }
250
+ </script>
251
+
252
+ <template>
253
+ <aside
254
+ v-if="visible"
255
+ class="search-results-view"
256
+ :class="{ 'has-query': hasQuery }"
257
+ aria-label="搜索结果"
258
+ @keydown.stop
259
+ >
260
+ <section class="search-panel">
261
+ <header class="search-header">
262
+ <div class="search-field" role="search">
263
+ <AppIcon name="search" :size="18" />
264
+ <input
265
+ ref="inputRef"
266
+ v-model="query"
267
+ aria-label="查找"
268
+ placeholder="查找当前文档中的主题"
269
+ @keydown="onSearchKeydown"
270
+ />
271
+ </div>
272
+ <button
273
+ type="button"
274
+ class="search-close"
275
+ title="关闭搜索 (Esc)"
276
+ aria-label="关闭搜索"
277
+ @click="emit('close')"
278
+ >
279
+ <AppIcon name="close" :size="17" />
280
+ </button>
281
+ </header>
282
+
283
+ <div class="replace-row">
284
+ <span class="replace-label">替换为</span>
285
+ <input
286
+ ref="replaceInputRef"
287
+ v-model="replacement"
288
+ aria-label="替换为"
289
+ placeholder="输入替换内容"
290
+ @keydown="onReplaceKeydown"
291
+ />
292
+ </div>
293
+
294
+ <div class="search-actions">
295
+ <span class="search-count">{{
296
+ hasQuery ? `${matches.length} 个结果` : '输入关键词开始搜索'
297
+ }}</span>
298
+ <span v-if="replaceNotice" class="replace-notice" role="status">{{ replaceNotice }}</span>
299
+ <div class="search-nav">
300
+ <button
301
+ type="button"
302
+ title="上一个 (Shift+Enter)"
303
+ :disabled="matches.length === 0"
304
+ @click="jump(activeIndex - 1)"
305
+ >
306
+ 上一处
307
+ </button>
308
+ <button
309
+ type="button"
310
+ title="下一个 (Enter)"
311
+ :disabled="matches.length === 0"
312
+ @click="jump(activeIndex + 1)"
313
+ >
314
+ 下一处
315
+ </button>
316
+ <span v-if="matches.length" class="search-position"
317
+ >{{ activeIndex + 1 }} / {{ matches.length }}</span
318
+ >
319
+ <button
320
+ type="button"
321
+ class="replace-button"
322
+ :disabled="matches.length === 0"
323
+ @click="replaceCurrent"
324
+ >
325
+ 替换
326
+ </button>
327
+ <button
328
+ type="button"
329
+ class="replace-button"
330
+ :disabled="matches.length === 0"
331
+ @click="replaceAll"
332
+ >
333
+ 全部替换
334
+ </button>
335
+ </div>
336
+ </div>
337
+ </section>
338
+
339
+ <div v-if="hasQuery" class="search-result-canvas">
340
+ <div class="result-view-title">搜索结果</div>
341
+ <div class="search-list">
342
+ <div
343
+ v-for="(node, index) in matches"
344
+ :key="node.id"
345
+ class="search-result"
346
+ :class="{ active: index === activeIndex }"
347
+ @click="activeIndex = index"
348
+ >
349
+ <span class="result-path">{{ pathFor(node) }}</span>
350
+ <span class="result-editor-row">
351
+ <button
352
+ v-if="node.content.checked !== null"
353
+ type="button"
354
+ class="result-checkbox"
355
+ :class="{ checked: node.content.checked }"
356
+ :aria-label="node.content.checked ? '标记为未完成' : '标记为已完成'"
357
+ @pointerdown.prevent
358
+ @click.stop="session.toggleChecked(node.id)"
359
+ >
360
+ <svg v-if="node.content.checked" viewBox="0 0 16 16" aria-hidden="true">
361
+ <path d="m3.5 8 3 3 6-6" />
362
+ </svg>
363
+ </button>
364
+ <RichInlineEditor
365
+ class="result-editor"
366
+ :class="{ done: node.content.checked === true }"
367
+ :editor-id="`search-${node.id}`"
368
+ :raw="node.content.image ? node.content.text : node.content.raw"
369
+ :active="true"
370
+ :done="node.content.checked === true"
371
+ @click.stop
372
+ @focus="activeIndex = index"
373
+ @commit="commitResult(node, $event)"
374
+ @keydown="onResultKeydown(node, $event)"
375
+ />
376
+ <button
377
+ type="button"
378
+ class="reveal-button"
379
+ title="在当前视图定位"
380
+ aria-label="在当前视图定位"
381
+ @pointerdown.prevent
382
+ @click.stop="reveal(node)"
383
+ >
384
+ ↗
385
+ </button>
386
+ </span>
387
+ </div>
388
+ <div v-if="matches.length === 0" class="search-empty">没有找到匹配主题</div>
389
+ </div>
390
+ </div>
391
+ </aside>
392
+ </template>
393
+
394
+ <style scoped>
395
+ .search-results-view {
396
+ position: absolute;
397
+ inset: 0;
398
+ z-index: 35;
399
+ pointer-events: none;
400
+ }
401
+ .search-results-view.has-query {
402
+ overflow: hidden;
403
+ background: var(--mm-panel-bg);
404
+ pointer-events: auto;
405
+ }
406
+ .search-panel {
407
+ position: absolute;
408
+ top: 10px;
409
+ right: 10px;
410
+ z-index: 2;
411
+ width: min(680px, calc(100% - 20px));
412
+ overflow: hidden;
413
+ border: 1px solid var(--mm-border);
414
+ border-radius: 12px;
415
+ background: color-mix(in srgb, var(--mm-panel-bg) 97%, transparent);
416
+ box-shadow: 0 14px 40px rgb(0 0 0 / 0.2);
417
+ pointer-events: auto;
418
+ }
419
+ .search-header {
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 8px;
423
+ padding: 12px;
424
+ border-bottom: 1px solid var(--mm-border);
425
+ }
426
+ .search-field {
427
+ display: flex;
428
+ flex: 1;
429
+ align-items: center;
430
+ gap: 8px;
431
+ height: 38px;
432
+ padding: 0 11px;
433
+ border: 1px solid var(--mm-border);
434
+ border-radius: 8px;
435
+ background: var(--mm-canvas-bg);
436
+ color: var(--mm-text-dim);
437
+ }
438
+ .search-field:focus-within {
439
+ border-color: var(--mm-accent);
440
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--mm-accent) 16%, transparent);
441
+ }
442
+ .search-field input {
443
+ min-width: 0;
444
+ flex: 1;
445
+ border: 0;
446
+ outline: 0;
447
+ background: transparent;
448
+ color: var(--mm-text);
449
+ font: inherit;
450
+ }
451
+ .search-close {
452
+ display: inline-flex;
453
+ width: 32px;
454
+ height: 32px;
455
+ align-items: center;
456
+ justify-content: center;
457
+ border: 0;
458
+ border-radius: 7px;
459
+ background: transparent;
460
+ color: var(--mm-text-dim);
461
+ cursor: pointer;
462
+ }
463
+ .search-close:hover {
464
+ background: var(--mm-hover);
465
+ color: var(--mm-text);
466
+ }
467
+ .replace-row {
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 10px;
471
+ padding: 10px 12px 0;
472
+ }
473
+ .replace-label {
474
+ width: 52px;
475
+ flex: none;
476
+ color: var(--mm-text-dim);
477
+ font-size: 13px;
478
+ }
479
+ .replace-row input {
480
+ min-width: 0;
481
+ height: 36px;
482
+ flex: 1;
483
+ padding: 0 10px;
484
+ border: 1px solid var(--mm-border);
485
+ border-radius: 7px;
486
+ outline: 0;
487
+ background: var(--mm-canvas-bg);
488
+ color: var(--mm-text);
489
+ font: inherit;
490
+ }
491
+ .replace-row input:focus {
492
+ border-color: var(--mm-accent);
493
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--mm-accent) 16%, transparent);
494
+ }
495
+ .search-actions {
496
+ display: flex;
497
+ min-height: 50px;
498
+ align-items: center;
499
+ gap: 9px;
500
+ padding: 7px 12px 10px;
501
+ color: var(--mm-text-dim);
502
+ font-size: 12px;
503
+ }
504
+ .search-count {
505
+ white-space: nowrap;
506
+ }
507
+ .replace-notice {
508
+ color: var(--mm-accent);
509
+ white-space: nowrap;
510
+ }
511
+ .search-nav {
512
+ display: flex;
513
+ min-width: 0;
514
+ align-items: center;
515
+ gap: 6px;
516
+ margin-left: auto;
517
+ }
518
+ .search-nav button {
519
+ min-height: 31px;
520
+ padding: 0 10px;
521
+ border: 1px solid var(--mm-border);
522
+ border-radius: 6px;
523
+ background: transparent;
524
+ color: var(--mm-text);
525
+ cursor: pointer;
526
+ white-space: nowrap;
527
+ }
528
+ .search-nav button:hover:not(:disabled) {
529
+ border-color: var(--mm-text-dim);
530
+ background: var(--mm-hover);
531
+ }
532
+ .search-nav button:disabled {
533
+ cursor: default;
534
+ opacity: 0.38;
535
+ }
536
+ .search-nav .replace-button {
537
+ background: var(--mm-text);
538
+ color: var(--mm-panel-bg);
539
+ }
540
+ .search-position {
541
+ min-width: 38px;
542
+ text-align: center;
543
+ white-space: nowrap;
544
+ }
545
+ .search-result-canvas {
546
+ position: absolute;
547
+ inset: 0;
548
+ overflow: auto;
549
+ padding: 162px 24px 48px;
550
+ }
551
+ .result-view-title {
552
+ width: min(820px, 100%);
553
+ margin: 0 auto 20px;
554
+ color: var(--mm-text);
555
+ font-size: 25px;
556
+ font-weight: 650;
557
+ }
558
+ .search-list {
559
+ width: min(820px, 100%);
560
+ margin: 0 auto;
561
+ }
562
+ .search-result {
563
+ display: block;
564
+ width: 100%;
565
+ margin-bottom: 8px;
566
+ padding: 11px 13px;
567
+ border: 1px solid transparent;
568
+ border-radius: 9px;
569
+ background: transparent;
570
+ color: var(--mm-text);
571
+ text-align: left;
572
+ cursor: default;
573
+ }
574
+ .search-result:hover,
575
+ .search-result.active {
576
+ border-color: var(--mm-border);
577
+ background: var(--mm-hover);
578
+ }
579
+ .result-path {
580
+ display: block;
581
+ overflow: hidden;
582
+ margin-bottom: 6px;
583
+ color: var(--mm-text-dim);
584
+ font-size: 12px;
585
+ text-overflow: ellipsis;
586
+ white-space: nowrap;
587
+ }
588
+ .result-editor-row {
589
+ display: flex;
590
+ align-items: center;
591
+ gap: 7px;
592
+ }
593
+ .result-editor {
594
+ min-width: 0;
595
+ flex: 1;
596
+ height: 34px;
597
+ padding: 0 4px;
598
+ border: 0;
599
+ border-bottom: 1px solid transparent;
600
+ outline: 0;
601
+ background: transparent;
602
+ color: var(--mm-text);
603
+ font: inherit;
604
+ font-size: 16px;
605
+ }
606
+ .result-editor:focus {
607
+ border-bottom-color: var(--mm-accent);
608
+ }
609
+ .result-editor.done {
610
+ color: var(--mm-text-dim);
611
+ text-decoration: line-through;
612
+ }
613
+ .result-checkbox {
614
+ display: inline-flex;
615
+ width: 16px;
616
+ height: 16px;
617
+ flex: none;
618
+ align-items: center;
619
+ justify-content: center;
620
+ padding: 0;
621
+ border: 1.5px solid var(--mm-text-dim);
622
+ border-radius: 3px;
623
+ background: transparent;
624
+ color: white;
625
+ }
626
+ .result-checkbox.checked {
627
+ border-color: var(--mm-accent);
628
+ background: var(--mm-accent);
629
+ }
630
+ .result-checkbox svg {
631
+ width: 13px;
632
+ height: 13px;
633
+ fill: none;
634
+ stroke: currentColor;
635
+ stroke-width: 2;
636
+ stroke-linecap: round;
637
+ stroke-linejoin: round;
638
+ }
639
+ .reveal-button {
640
+ width: 26px;
641
+ height: 26px;
642
+ border: 0;
643
+ border-radius: 5px;
644
+ background: transparent;
645
+ color: var(--mm-text-dim);
646
+ cursor: pointer;
647
+ }
648
+ .reveal-button:hover {
649
+ background: var(--mm-panel-bg);
650
+ color: var(--mm-accent);
651
+ }
652
+ .search-empty {
653
+ padding: 34px 12px;
654
+ color: var(--mm-text-dim);
655
+ text-align: center;
656
+ }
657
+
658
+ @media (max-width: 700px) {
659
+ .search-actions {
660
+ align-items: flex-start;
661
+ flex-wrap: wrap;
662
+ }
663
+ .search-nav {
664
+ width: 100%;
665
+ margin-left: 0;
666
+ overflow-x: auto;
667
+ }
668
+ .search-result-canvas {
669
+ padding-top: 202px;
670
+ }
671
+ }
672
+ </style>
@@ -17,7 +17,7 @@ const props = withDefaults(
17
17
  placement?: 'fixed' | 'canvas-bottom'
18
18
  activeFormats?: Partial<Record<InlineFormat, boolean>>
19
19
  }>(),
20
- { placement: 'fixed' }
20
+ { placement: 'fixed', position: undefined, activeFormats: undefined }
21
21
  )
22
22
 
23
23
  const emit = defineEmits<{
@@ -10,6 +10,11 @@ export type {
10
10
  ExcalidrawViewState
11
11
  } from '../excalidraw/renderer'
12
12
  export { parseExcalidrawScene, sceneCacheKey, svgCache, SvgCache } from '../excalidraw/scene'
13
+ /**
14
+ * 导出器(`exportToSvg` + 字体内联)只能动态取:入口本身不得把 Excalidraw 拖进静态依赖图。
15
+ * Desk 用它把画布写成笔记里引用的那张 `.svg`。
16
+ */
17
+ export const loadExcalidrawExporter = () => import('../excalidraw/exporter')
13
18
  /** 部署站点需要把官方字体指向站点内的目录(离线自包含) */
14
19
  export { setExcalidrawAssetPath, getExcalidrawAssetPath } from '../excalidraw/fonts'
15
20
  export type { ExcalidrawScene, SceneParseResult } from '../excalidraw/scene'