@tnotesjs/ui 0.4.2 → 0.4.3

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.
@@ -1,17 +1,20 @@
1
1
  import { parseInlineSegments } from '@tnotesjs/mindmap-core'
2
2
  import { defineComponent, h } from 'vue'
3
3
 
4
+ import { useMindmapInlineOptions } from './inlineOptions'
5
+
4
6
  export default defineComponent({
5
7
  name: 'MindmapInlineRuns',
6
8
  props: {
7
9
  raw: { type: String, default: '' }
8
10
  },
9
11
  setup(props) {
12
+ const inlineOptions = useMindmapInlineOptions()
10
13
  return () =>
11
14
  h(
12
15
  'span',
13
16
  { class: 'mindmap-inline-runs' },
14
- parseInlineSegments(props.raw).map((segment) => {
17
+ parseInlineSegments(props.raw, inlineOptions()).map((segment) => {
15
18
  const classes = Object.entries(segment.marks)
16
19
  .filter(([, enabled]) => enabled)
17
20
  .map(([name]) => `is-${name}`)
@@ -11,8 +11,10 @@ import {
11
11
  resolvedExpandLevel,
12
12
  stepExpandLevel
13
13
  } from './expandLevel'
14
+ import { setFullscreenLayer } from '../../browser/fullscreenLayer'
14
15
  import { FORCE_EXIT_FULLSCREEN_EVENT, reconcileMindmapFullscreen } from './fullscreenFlag'
15
16
  import { normalizeMindmapMarkdown } from './markdown'
17
+ import { provideMindmapInlineOptions } from './inlineOptions'
16
18
  import MindmapOutlineNode from './MindmapOutlineNode.vue'
17
19
  import MindmapViewIcon from './MindmapViewIcon.vue'
18
20
  import FocusBreadcrumbs from './FocusBreadcrumbs.vue'
@@ -96,6 +98,11 @@ const props = withDefaults(
96
98
  * (e.g. `./assets/foo.png`). Required for editable paste-to-assets.
97
99
  */
98
100
  writeAsset?: (blob: Blob) => Promise<{ relativePath: string; alt?: string }>
101
+ /**
102
+ * 链接引用定义(`[id]: url`,Desk 从围栏外的笔记正文收集)。
103
+ * 节点里的 `[文字][id]` / `[文字][]` / `[文字]` 据此显示为链接,写回时保留引用写法。
104
+ */
105
+ linkDefinitions?: ReadonlyMap<string, string> | Record<string, string>
99
106
  }>(),
100
107
  {
101
108
  content: '',
@@ -106,7 +113,8 @@ const props = withDefaults(
106
113
  height: undefined,
107
114
  isDark: undefined,
108
115
  resolveImageSrc: undefined,
109
- writeAsset: undefined
116
+ writeAsset: undefined,
117
+ linkDefinitions: undefined
110
118
  }
111
119
  )
112
120
 
@@ -220,7 +228,8 @@ function rebuildSession(): void {
220
228
  suppressChangeEmit = true
221
229
  const next = new MindmapSession({
222
230
  markdown: normalizedContent.value,
223
- fileName: 'mindmap-preview.tn-mindmap.md'
231
+ fileName: 'mindmap-preview.tn-mindmap.md',
232
+ definitions: props.linkDefinitions
224
233
  })
225
234
  applyInitialExpandLevel(next, expandLevel.value)
226
235
  const invalidate = () => {
@@ -657,6 +666,19 @@ function handleDocumentKeydown(event: KeyboardEvent): void {
657
666
  canvasHost.value?.blur()
658
667
  }
659
668
 
669
+ provideMindmapInlineOptions(() => session.value?.inlineOptions ?? {})
670
+
671
+ /** 定义变了(笔记里改了 `[1]: url`)就按新定义重建会话;内容不变时不重建。 */
672
+ const definitionsKey = computed(() => {
673
+ const defs = props.linkDefinitions
674
+ if (!defs) return ''
675
+ const entries = defs instanceof Map ? [...defs.entries()] : Object.entries(defs)
676
+ return JSON.stringify(entries)
677
+ })
678
+ watch(definitionsKey, (value, previous) => {
679
+ if (value !== previous && session.value) rebuildSession()
680
+ })
681
+
660
682
  watch(
661
683
  normalizedContent,
662
684
  (value) => {
@@ -722,9 +744,12 @@ onMounted(() => {
722
744
  createViewer()
723
745
  })
724
746
 
747
+ watch(isFullscreen, (on) => setFullscreenLayer(previewRoot.value, on), { flush: 'sync' })
748
+
725
749
  onBeforeUnmount(() => {
726
750
  mounted = false
727
751
  exitFullscreenOverlay()
752
+ setFullscreenLayer(previewRoot.value, false)
728
753
  unregisterFullscreenOwner?.()
729
754
  unregisterFullscreenOwner = null
730
755
  themeObserver?.disconnect()
@@ -1178,11 +1203,6 @@ onBeforeUnmount(() => {
1178
1203
  .mindmap-canvas-host:deep(.mm-edit-input .inline-run.underline.strike) {
1179
1204
  text-decoration-line: underline line-through;
1180
1205
  }
1181
- .mindmap-canvas-host:deep(.mm-edit-input .inline-run.highlight:not(.code)) {
1182
- padding: 0 1px;
1183
- border-radius: 2px;
1184
- background: var(--tn-c-mark-bg, rgba(255, 221, 0, 0.45));
1185
- }
1186
1206
  .mindmap-canvas-host:deep(.mm-edit-input .inline-run.code) {
1187
1207
  padding: 0 4px;
1188
1208
  border: 1px solid var(--tn-c-inline-code-border);
@@ -1342,11 +1362,6 @@ onBeforeUnmount(() => {
1342
1362
  .mindmap-outline :deep(.is-strike) {
1343
1363
  text-decoration: line-through;
1344
1364
  }
1345
- .mindmap-outline :deep(.is-highlight) {
1346
- padding: 0 2px;
1347
- border-radius: 2px;
1348
- background: var(--tn-c-mark-bg, rgba(255, 221, 0, 0.45));
1349
- }
1350
1365
  .mindmap-outline :deep(.is-code) {
1351
1366
  padding: 0 4px;
1352
1367
  border: 1px solid var(--tn-c-inline-code-border);
@@ -66,9 +66,6 @@ defineProps<{ name: string; size?: number }>()
66
66
  <circle cx="9" cy="9" r="2" />
67
67
  <path d="m4 17 5-5 3 3 2-2 6 6" />
68
68
  </template>
69
- <template v-else-if="name === 'highlight'">
70
- <path d="m14.5 4.5 5 5-8.2 8.2-5.6.6.6-5.6zM12.5 6.5l5 5M4 21h16" />
71
- </template>
72
69
  <template v-else-if="name === 'link'">
73
70
  <path
74
71
  d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.2 1.2M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.2-1.2"
@@ -48,7 +48,7 @@ const textFormats = computed<Partial<Record<InlineFormat, boolean>>>(() => {
48
48
  const state = editState.value
49
49
  const selection = textSelection.value
50
50
  if (!state || !selection) return {}
51
- const formats: InlineFormat[] = ['bold', 'italic', 'underline', 'strike', 'highlight', 'code']
51
+ const formats: InlineFormat[] = ['bold', 'italic', 'underline', 'strike', 'code']
52
52
  return Object.fromEntries(
53
53
  formats.map((format) => [
54
54
  format,
@@ -357,4 +357,19 @@ onBeforeUnmount(() => {
357
357
  outline: none;
358
358
  background: var(--mm-canvas-bg);
359
359
  }
360
+ /* CanvasEditor 在宿主上切换这些状态类;光标规则原在已删除的独立 app 样式里,这里补回。 */
361
+ .mindmap-view-host.mm-editor {
362
+ cursor: default;
363
+ }
364
+ .mindmap-view-host.mm-editor.is-space-pan {
365
+ cursor: grab;
366
+ }
367
+ .mindmap-view-host.mm-editor.is-panning,
368
+ .mindmap-view-host.mm-editor.is-node-dragging {
369
+ cursor: grabbing;
370
+ }
371
+ .mindmap-view-host.mm-editor.is-link-hover,
372
+ .mindmap-view-host.mm-editor.is-control-hover {
373
+ cursor: pointer;
374
+ }
360
375
  </style>
@@ -116,6 +116,15 @@ function measureTextBlockHeight(
116
116
  return Math.max(minHeight, lines.length * lineHeight + 8)
117
117
  }
118
118
 
119
+ function imageSizeLabel(node: MindmapNode): string {
120
+ if (!liveImageWidth.value.has(node.id) || !node.content.image) return ''
121
+ const width = displayImageWidth(node)
122
+ const aspect = imageAspects.value.get(node.content.image.src) ?? 1
123
+ const height = Math.min(Math.round(width / Math.max(aspect, 0.15)), IMG_MAX_H)
124
+ if (width < 88 || height < 32) return ''
125
+ return `${Math.round(width)} x ${height}`
126
+ }
127
+
119
128
  function displayImageWidth(node: MindmapNode): number {
120
129
  const live = liveImageWidth.value.get(node.id)
121
130
  if (live != null) return live
@@ -376,7 +385,7 @@ const selectedTextFormats = computed<Partial<Record<InlineFormat, boolean>>>(()
376
385
  if (props.version < 0) return {}
377
386
  const selection = textSelection.value
378
387
  if (!selection) return {}
379
- const formats: InlineFormat[] = ['bold', 'italic', 'underline', 'strike', 'highlight', 'code']
388
+ const formats: InlineFormat[] = ['bold', 'italic', 'underline', 'strike', 'code']
380
389
  return Object.fromEntries(
381
390
  formats.map((format) => [
382
391
  format,
@@ -703,7 +712,6 @@ function inlineFormatShortcut(event: KeyboardEvent, hasSelection = true): Inline
703
712
  if (key === 'u' && !event.shiftKey) return 'underline'
704
713
  if (key === 'enter' && !event.shiftKey && hasSelection) return 'strike'
705
714
  if ((key === 's' || key === 'x') && event.shiftKey) return 'strike'
706
- if (key === 'h' && event.shiftKey) return 'highlight'
707
715
  if (key === 'e' && !event.shiftKey) return 'code'
708
716
  return null
709
717
  }
@@ -1259,7 +1267,7 @@ function onEditorPasteMultiline(node: MindmapNode, text: string) {
1259
1267
  return `${m[1]}- ${m[2]}`
1260
1268
  })
1261
1269
  .join('\n')
1262
- const { doc: tmp } = parseMarkdown(`# _\n\n${fragment}\n`)
1270
+ const { doc: tmp } = parseMarkdown(`# _\n\n${fragment}\n`, '_', session.inlineOptions)
1263
1271
  const nodes = tmp.root.children.map((c) => cloneSubtree(c))
1264
1272
  if (nodes.length === 0) return
1265
1273
 
@@ -1506,7 +1514,19 @@ function onImageResizeUp() {
1506
1514
 
1507
1515
  // ---------- 键盘导航(焦点在容器、非编辑态时) ----------
1508
1516
 
1517
+ function isNativeTextEntry(target: EventTarget | null): boolean {
1518
+ if (!(target instanceof HTMLElement)) return false
1519
+ return (
1520
+ target instanceof HTMLInputElement ||
1521
+ target instanceof HTMLTextAreaElement ||
1522
+ target.isContentEditable
1523
+ )
1524
+ }
1525
+
1509
1526
  function onKeydown(e: KeyboardEvent) {
1527
+ // 容器内的独立输入框(如 LinkPopover 的链接地址)冒泡上来的按键交给其原生行为,
1528
+ // 否则 ←/→ 会被当成折叠/展开、Backspace/Delete 会删节点。
1529
+ if (e.target !== e.currentTarget && isNativeTextEntry(e.target)) return
1510
1530
  const session = props.session
1511
1531
  const sel = session.selectedNode
1512
1532
  const mod = e.metaKey || e.ctrlKey
@@ -2035,6 +2055,9 @@ function onDragUp() {
2035
2055
  @load="onOutlineImageLoad(focusRoot.content.image.src, $event)"
2036
2056
  @click="onImageClick(focusRoot, $event)"
2037
2057
  />
2058
+ <span v-if="imageSizeLabel(focusRoot)" class="row-image-size">{{
2059
+ imageSizeLabel(focusRoot)
2060
+ }}</span>
2038
2061
  <span
2039
2062
  class="row-image-handle"
2040
2063
  title="拖动调节宽度"
@@ -2181,6 +2204,9 @@ function onDragUp() {
2181
2204
  @load="onOutlineImageLoad(row.node.content.image.src, $event)"
2182
2205
  @click="onImageClick(row.node, $event)"
2183
2206
  />
2207
+ <span v-if="imageSizeLabel(row.node)" class="row-image-size">{{
2208
+ imageSizeLabel(row.node)
2209
+ }}</span>
2184
2210
  <span
2185
2211
  class="row-image-handle"
2186
2212
  title="拖动调节宽度"
@@ -2360,6 +2386,21 @@ function onDragUp() {
2360
2386
  cursor: zoom-in;
2361
2387
  user-select: none;
2362
2388
  }
2389
+ .row-image-size {
2390
+ position: absolute;
2391
+ top: 50%;
2392
+ left: 50%;
2393
+ z-index: 2;
2394
+ transform: translate(-50%, -50%);
2395
+ padding: 4px 8px;
2396
+ border-radius: 6px;
2397
+ background: rgb(0 0 0 / 62%);
2398
+ color: #fff;
2399
+ font-size: 12px;
2400
+ line-height: 1.4;
2401
+ white-space: nowrap;
2402
+ pointer-events: none;
2403
+ }
2363
2404
  .row-image-handle {
2364
2405
  position: absolute;
2365
2406
  right: 0;
@@ -12,6 +12,8 @@ import {
12
12
  } from '@tnotesjs/mindmap-core'
13
13
  import type { InlineLink, RichInlineEditorElement } from '@tnotesjs/mindmap-core'
14
14
 
15
+ import { useMindmapInlineOptions } from '../inlineOptions'
16
+
15
17
  const props = withDefaults(
16
18
  defineProps<{
17
19
  editorId: string
@@ -43,6 +45,7 @@ const emit = defineEmits<{
43
45
  linkClick: [link: InlineLink, event: Event]
44
46
  }>()
45
47
 
48
+ const inlineOptions = useMindmapInlineOptions()
46
49
  const rootRef = ref<RichInlineEditorElement>()
47
50
  const draftRaw = ref(props.raw)
48
51
  let sourceRaw = props.raw
@@ -50,17 +53,17 @@ let dirty = false
50
53
  let composing = false
51
54
  let compositionBaseRaw = props.raw
52
55
 
53
- const text = computed(() => stripInline(draftRaw.value))
56
+ const text = computed(() => stripInline(draftRaw.value, inlineOptions()))
54
57
 
55
58
  function renderInlineDom() {
56
59
  const root = rootRef.value
57
60
  if (!root) return
58
61
  const fragment = document.createDocumentFragment()
59
62
  let plainOffset = 0
60
- for (const segment of parseInlineSegments(draftRaw.value)) {
63
+ for (const segment of parseInlineSegments(draftRaw.value, inlineOptions())) {
61
64
  const run = document.createElement('span')
62
65
  run.className = 'inline-run'
63
- for (const format of ['bold', 'italic', 'underline', 'strike', 'highlight', 'code'] as const) {
66
+ for (const format of ['bold', 'italic', 'underline', 'strike', 'code'] as const) {
64
67
  if (segment.marks[format]) run.classList.add(format)
65
68
  }
66
69
  if (segment.link) {
@@ -89,7 +92,7 @@ function renderInlineDom() {
89
92
  }
90
93
 
91
94
  function payload(): DraftPayload {
92
- return { raw: draftRaw.value, text: stripInline(draftRaw.value) }
95
+ return { raw: draftRaw.value, text: stripInline(draftRaw.value, inlineOptions()) }
93
96
  }
94
97
 
95
98
  function renderDraft(
@@ -120,7 +123,14 @@ function replaceSelection(textToInsert: string) {
120
123
  focus: text.value.length,
121
124
  direction: 'forward' as const
122
125
  }
123
- const nextRaw = replaceInlineRange(draftRaw.value, selection.start, selection.end, textToInsert)
126
+ const nextRaw = replaceInlineRange(
127
+ draftRaw.value,
128
+ selection.start,
129
+ selection.end,
130
+ textToInsert,
131
+ undefined,
132
+ inlineOptions()
133
+ )
124
134
  const caret = selection.start + textToInsert.length
125
135
  renderDraft(nextRaw, { start: caret })
126
136
  }
@@ -148,7 +158,14 @@ function onBeforeInput(event: InputEvent) {
148
158
  if (event.inputType === 'insertText' || event.inputType === 'insertReplacementText') {
149
159
  if (event.data == null) return
150
160
  event.preventDefault()
151
- const nextRaw = replaceInlineRange(draftRaw.value, selection.start, selection.end, event.data)
161
+ const nextRaw = replaceInlineRange(
162
+ draftRaw.value,
163
+ selection.start,
164
+ selection.end,
165
+ event.data,
166
+ undefined,
167
+ inlineOptions()
168
+ )
152
169
  renderDraft(nextRaw, { start: selection.start + event.data.length })
153
170
  return
154
171
  }
@@ -159,7 +176,10 @@ function onBeforeInput(event: InputEvent) {
159
176
  selection.start === selection.end
160
177
  ? previousGraphemeOffset(text.value, selection.start)
161
178
  : selection.start
162
- renderDraft(replaceInlineRange(draftRaw.value, start, selection.end, ''), { start })
179
+ renderDraft(
180
+ replaceInlineRange(draftRaw.value, start, selection.end, '', undefined, inlineOptions()),
181
+ { start }
182
+ )
163
183
  return
164
184
  }
165
185
 
@@ -169,17 +189,30 @@ function onBeforeInput(event: InputEvent) {
169
189
  selection.start === selection.end
170
190
  ? nextGraphemeOffset(text.value, selection.end)
171
191
  : selection.end
172
- renderDraft(replaceInlineRange(draftRaw.value, selection.start, end, ''), {
173
- start: selection.start
174
- })
192
+ renderDraft(
193
+ replaceInlineRange(draftRaw.value, selection.start, end, '', undefined, inlineOptions()),
194
+ {
195
+ start: selection.start
196
+ }
197
+ )
175
198
  return
176
199
  }
177
200
 
178
201
  if (event.inputType === 'deleteByCut') {
179
202
  event.preventDefault()
180
- renderDraft(replaceInlineRange(draftRaw.value, selection.start, selection.end, ''), {
181
- start: selection.start
182
- })
203
+ renderDraft(
204
+ replaceInlineRange(
205
+ draftRaw.value,
206
+ selection.start,
207
+ selection.end,
208
+ '',
209
+ undefined,
210
+ inlineOptions()
211
+ ),
212
+ {
213
+ start: selection.start
214
+ }
215
+ )
183
216
  return
184
217
  }
185
218
 
@@ -201,7 +234,10 @@ function onInput(event: InputEvent) {
201
234
  const nextText = (root.textContent ?? '').replace(/[\r\n]/g, '')
202
235
  if (nextText === text.value) return
203
236
  const selection = richSelectionOffsets(root)
204
- renderDraft(replaceInlineDisplayText(draftRaw.value, nextText), selection ?? undefined)
237
+ renderDraft(
238
+ replaceInlineDisplayText(draftRaw.value, nextText, inlineOptions()),
239
+ selection ?? undefined
240
+ )
205
241
  }
206
242
 
207
243
  function onCompositionStart() {
@@ -216,7 +252,10 @@ function finishComposition() {
216
252
  const nextText = (root.textContent ?? '').replace(/[\r\n]/g, '')
217
253
  const selection = richSelectionOffsets(root)
218
254
  composing = false
219
- renderDraft(replaceInlineDisplayText(compositionBaseRaw, nextText), selection ?? undefined)
255
+ renderDraft(
256
+ replaceInlineDisplayText(compositionBaseRaw, nextText, inlineOptions()),
257
+ selection ?? undefined
258
+ )
220
259
  }
221
260
 
222
261
  function onCompositionEnd() {
@@ -308,9 +347,9 @@ function installCompatibilityApi(root: RichInlineEditorElement) {
308
347
  Object.defineProperties(root, {
309
348
  value: {
310
349
  configurable: true,
311
- get: () => stripInline(draftRaw.value),
350
+ get: () => stripInline(draftRaw.value, inlineOptions()),
312
351
  set: (next: string) => {
313
- renderDraft(replaceInlineDisplayText(draftRaw.value, String(next)))
352
+ renderDraft(replaceInlineDisplayText(draftRaw.value, String(next), inlineOptions()))
314
353
  }
315
354
  },
316
355
  rawValue: { configurable: true, get: () => draftRaw.value },
@@ -413,11 +452,6 @@ onBeforeUnmount(emitCommit)
413
452
  .rich-inline-editor :deep(.inline-run.underline.strike) {
414
453
  text-decoration-line: underline line-through;
415
454
  }
416
- .rich-inline-editor :deep(.inline-run.highlight:not(.code)) {
417
- padding: 0 1px;
418
- border-radius: 2px;
419
- background: var(--tn-c-mark-bg, rgba(255, 221, 0, 0.45));
420
- }
421
455
  .rich-inline-editor :deep(.inline-run.code) {
422
456
  padding: 0 4px;
423
457
  border: 1px solid var(--tn-c-inline-code-border, #e7e9e8);
@@ -78,7 +78,6 @@ function inlineFormatShortcut(event: KeyboardEvent, hasSelection = true): Inline
78
78
  if (key === 'u' && !event.shiftKey) return 'underline'
79
79
  if (key === 'enter' && !event.shiftKey && hasSelection) return 'strike'
80
80
  if ((key === 's' || key === 'x') && event.shiftKey) return 'strike'
81
- if (key === 'h' && event.shiftKey) return 'highlight'
82
81
  if (key === 'e' && !event.shiftKey) return 'code'
83
82
  return null
84
83
  }
@@ -182,7 +181,14 @@ function replacedNodeValue(
182
181
  }
183
182
  let raw = node.content.raw
184
183
  for (const range of [...selectedRanges].reverse()) {
185
- raw = replaceInlineRange(raw, range.start, range.end, replacement.value)
184
+ raw = replaceInlineRange(
185
+ raw,
186
+ range.start,
187
+ range.end,
188
+ replacement.value,
189
+ undefined,
190
+ props.session.inlineOptions
191
+ )
186
192
  }
187
193
  return { raw, text: node.content.text, count: selectedRanges.length }
188
194
  }
@@ -44,8 +44,7 @@ const formats: Array<{ id: InlineFormat; text: string; label: string }> = [
44
44
  { id: 'bold', text: 'B', label: `加粗 (${primaryShortcut('B')})` },
45
45
  { id: 'italic', text: 'I', label: `斜体 (${primaryShortcut('I')})` },
46
46
  { id: 'underline', text: 'U', label: `下划线 (${primaryShortcut('U')})` },
47
- { id: 'strike', text: 'S', label: `删除线 (${primaryShortcut('Enter')})` },
48
- { id: 'highlight', text: '▰', label: '高亮' }
47
+ { id: 'strike', text: 'S', label: `删除线 (${primaryShortcut('Enter')})` }
49
48
  ]
50
49
 
51
50
  const toolbarRef = ref<HTMLElement | null>(null)
@@ -113,8 +112,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', measureToolbar))
113
112
  :aria-label="item.label"
114
113
  @click="emit('format', item.id)"
115
114
  >
116
- <AppIcon v-if="item.id === 'highlight'" name="highlight" :size="21" />
117
- <template v-else>{{ item.text }}</template>
115
+ {{ item.text }}
118
116
  </button>
119
117
  <span class="toolbar-divider" />
120
118
  </template>
@@ -284,9 +282,6 @@ onBeforeUnmount(() => window.removeEventListener('resize', measureToolbar))
284
282
  .format-button.is-strike {
285
283
  text-decoration: line-through;
286
284
  }
287
- .format-button.is-highlight {
288
- color: #d3c900;
289
- }
290
285
  .tool-button.danger {
291
286
  color: #e14f5b;
292
287
  }
@@ -1,12 +1,12 @@
1
1
  import { cloneSubtree, parseMarkdown } from '@tnotesjs/mindmap-core'
2
- import type { MindmapNode, MindmapSession } from '@tnotesjs/mindmap-core'
2
+ import type { InlineParseOptions, MindmapNode, MindmapSession } from '@tnotesjs/mindmap-core'
3
3
 
4
4
  const LIST_LINE_RE = /^\s*[-*+]\s+/
5
5
 
6
6
  /** In-app fallback when Electron denies async Clipboard API (common in Desk). */
7
7
  let mindmapClipboardBuffer = ''
8
8
 
9
- function clipboardNodes(text: string): MindmapNode[] {
9
+ function clipboardNodes(text: string, options?: InlineParseOptions): MindmapNode[] {
10
10
  const fragment = text
11
11
  .split(/\r?\n/)
12
12
  .filter((line) => line.trim() !== '')
@@ -17,7 +17,7 @@ function clipboardNodes(text: string): MindmapNode[] {
17
17
  })
18
18
  .join('\n')
19
19
  if (!fragment) return []
20
- const parsed = parseMarkdown(`# _\n\n${fragment}\n`)
20
+ const parsed = parseMarkdown(`# _\n\n${fragment}\n`, '_', options)
21
21
  if (!parsed.valid) return []
22
22
  return parsed.doc.root.children.map((node) => cloneSubtree(node))
23
23
  }
@@ -74,7 +74,7 @@ export function pasteCanvasOutline(
74
74
  anchorId: string,
75
75
  text: string
76
76
  ): string[] {
77
- const nodes = clipboardNodes(text)
77
+ const nodes = clipboardNodes(text, session.inlineOptions)
78
78
  const anchor = session.document.find(anchorId)
79
79
  if (!anchor || nodes.length === 0) return []
80
80
  const insertedIds: string[] = []
@@ -0,0 +1,21 @@
1
+ import { inject, provide } from 'vue'
2
+
3
+ import type { InlineParseOptions } from '@tnotesjs/mindmap-core'
4
+ import type { InjectionKey } from 'vue'
5
+
6
+ /**
7
+ * 当前导图的行内解析选项(链接引用定义等)。由 Mindmap.vue 按会话提供,
8
+ * 大纲行、行内编辑器据此把 `[文字][id]` 渲染成链接、编辑后写回引用写法。
9
+ */
10
+ const MINDMAP_INLINE_OPTIONS: InjectionKey<() => InlineParseOptions> =
11
+ Symbol('mindmapInlineOptions')
12
+
13
+ const NO_OPTIONS = (): InlineParseOptions => ({})
14
+
15
+ export function provideMindmapInlineOptions(get: () => InlineParseOptions): void {
16
+ provide(MINDMAP_INLINE_OPTIONS, get)
17
+ }
18
+
19
+ export function useMindmapInlineOptions(): () => InlineParseOptions {
20
+ return inject(MINDMAP_INLINE_OPTIONS, NO_OPTIONS)
21
+ }
@@ -1,9 +1,10 @@
1
1
  <script setup>
2
2
  import { marked } from 'marked'
3
- import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
3
+ import { computed, ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
4
4
 
5
5
  import RightClickMenu from './RightClickMenu.vue'
6
6
  import { WORD_LIST_FEATURES_FULL, resolveWordListFeatures } from './wordListFeatures'
7
+ import { wordListStorageKey } from './wordListStorage'
7
8
 
8
9
  const DEFAULT_WORDS_BASE_URL = 'https://github.com/tnotesjs/en-words/blob/main/'
9
10
  const DEFAULT_WORDS_RAW_BASE_URL =
@@ -30,6 +31,14 @@ const props = defineProps({
30
31
  features: {
31
32
  type: Object,
32
33
  default: () => ({ ...WORD_LIST_FEATURES_FULL })
34
+ },
35
+ /**
36
+ * 勾选态 localStorage 键的作用域。不传时用 `window.location.pathname`(SSG 一页一路径);
37
+ * Desk 单页承载多篇笔记,需注入知识库 + 笔记维度,避免勾选态跨笔记串用。
38
+ */
39
+ storageScope: {
40
+ type: String,
41
+ default: ''
33
42
  }
34
43
  })
35
44
 
@@ -51,8 +60,18 @@ const sortedWords = computed(() => {
51
60
  const islandWords = computed(() => encodeURIComponent(JSON.stringify(sortedWords.value)))
52
61
  const checkedStates = ref({})
53
62
 
63
+ const storageKey = (word) => wordListStorageKey(props.storageScope, pathname, word)
64
+
65
+ const loadCheckedStates = () => {
66
+ const next = {}
67
+ sortedWords.value.forEach((word) => {
68
+ next[word] = localStorage.getItem(storageKey(word)) === 'true'
69
+ })
70
+ checkedStates.value = next
71
+ }
72
+
54
73
  const updateCheckedState = (word, isChecked) => {
55
- const key = `${pathname}-${word}`
74
+ const key = storageKey(word)
56
75
  checkedStates.value[word] = isChecked
57
76
  localStorage.setItem(key, isChecked)
58
77
  }
@@ -66,8 +85,7 @@ const checkAll = () => {
66
85
 
67
86
  const reset = () => {
68
87
  sortedWords.value.forEach((word) => {
69
- const key = `${pathname}-${word}`
70
- localStorage.removeItem(key)
88
+ localStorage.removeItem(storageKey(word))
71
89
  checkedStates.value[word] = false
72
90
  })
73
91
  hideContextMenu()
@@ -448,11 +466,7 @@ const handlePronounce = (word, lang = 'en-GB') => {
448
466
  // hooks ----------------------------------------------------------
449
467
 
450
468
  onMounted(() => {
451
- sortedWords.value.forEach((word) => {
452
- const key = `${pathname}-${word}`
453
- const storedState = localStorage.getItem(key)
454
- checkedStates.value[word] = storedState === 'true'
455
- })
469
+ loadCheckedStates()
456
470
 
457
471
  if (!isMobile.value && features.value.enableWordData) preloadWords()
458
472
 
@@ -462,6 +476,12 @@ onMounted(() => {
462
476
  }
463
477
  })
464
478
 
479
+ // 作用域或词表变了(同一实例换了笔记 / 词表)就按新键重新回读,不沿用旧勾选态
480
+ watch(
481
+ () => [props.storageScope, islandWords.value],
482
+ () => loadCheckedStates()
483
+ )
484
+
465
485
  /**
466
486
  * 销毁时清理定时器
467
487
  */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * WordList 勾选态的 localStorage 键。
3
+ *
4
+ * - SSG:一页一个 pathname,不传作用域,沿用 `${pathname}-${word}`(兼容已存的勾选记录)。
5
+ * - Desk:所有笔记共用同一个 index.html,pathname 固定,必须由宿主注入笔记维度的作用域
6
+ * (见 {@link deskWordListStorageScope}),否则勾选态会在笔记之间串用。
7
+ */
8
+ export function wordListStorageKey(
9
+ storageScope: string | null | undefined,
10
+ pathname: string,
11
+ word: string
12
+ ): string {
13
+ const scope = storageScope ? storageScope : pathname
14
+ return `${scope}-${word}`
15
+ }
16
+
17
+ /** Desk 的作用域:知识库 + 笔记 uuid。缺任一项时返回空串,由组件退回 pathname。 */
18
+ export function deskWordListStorageScope(knowledgeBaseId: string, noteUuid: string): string {
19
+ if (!knowledgeBaseId || !noteUuid) return ''
20
+ return `tnotes-desk:word-list:${knowledgeBaseId}:${noteUuid}`
21
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  resolveWordListFeatures
12
12
  } from './components/WordList/wordListFeatures'
13
13
  export type { WordListFeatures } from './components/WordList/wordListFeatures'
14
+ export { deskWordListStorageScope, wordListStorageKey } from './components/WordList/wordListStorage'
14
15
  export {
15
16
  clampMindmapHeight,
16
17
  MINDMAP_MAX_HEIGHT,
@@ -31,6 +32,7 @@ export { default as CodeBlock } from './components/CodeBlock/CodeBlock.vue'
31
32
  export { default as CodeGroup } from './components/CodeGroup/CodeGroup.vue'
32
33
  export { default as ImagePreview } from './components/ImagePreview/ImagePreview.vue'
33
34
  export * from './browser/clipboard'
35
+ export * from './browser/fullscreenLayer'
34
36
  export * from './browser/theme'
35
37
  export * from './code/highlight'
36
38
  export {
@@ -39,3 +41,4 @@ export {
39
41
  createSwiperTabNav,
40
42
  wrapSlideIndex
41
43
  } from './swiper/hydrate'
44
+ export { default as ContributionHeatmap } from './components/ContributionHeatmap/ContributionHeatmap.vue'