@tnotesjs/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +68 -0
  4. package/src/components/BilibiliVideo/BilibiliVideo.vue +55 -0
  5. package/src/components/Footprints/Footprints.vue +339 -0
  6. package/src/components/Footprints/parse.ts +122 -0
  7. package/src/components/Mermaid/Mermaid.vue +553 -0
  8. package/src/components/Mermaid/icons/icon__center_off.svg +1 -0
  9. package/src/components/Mermaid/icons/icon__center_on.svg +1 -0
  10. package/src/components/Mermaid/icons/icon__check.svg +3 -0
  11. package/src/components/Mermaid/icons/icon__clipboard.svg +8 -0
  12. package/src/components/Mermaid/icons/icon__fullscreen.svg +1 -0
  13. package/src/components/Mermaid/icons/icon__fullscreen_exit.svg +1 -0
  14. package/src/components/Mindmap/FocusBreadcrumbs.test.ts +265 -0
  15. package/src/components/Mindmap/FocusBreadcrumbs.vue +436 -0
  16. package/src/components/Mindmap/InlineRuns.ts +25 -0
  17. package/src/components/Mindmap/Mindmap.vue +1210 -0
  18. package/src/components/Mindmap/MindmapOutlineNode.vue +62 -0
  19. package/src/components/Mindmap/MindmapViewIcon.vue +41 -0
  20. package/src/components/Mindmap/editor/AppIcon.vue +107 -0
  21. package/src/components/Mindmap/editor/CanvasContextMenu.vue +157 -0
  22. package/src/components/Mindmap/editor/LinkPopover.vue +83 -0
  23. package/src/components/Mindmap/editor/MarkdownView.vue +163 -0
  24. package/src/components/Mindmap/editor/MindmapView.vue +253 -0
  25. package/src/components/Mindmap/editor/OutlineView.vue +2494 -0
  26. package/src/components/Mindmap/editor/RichInlineEditor.vue +393 -0
  27. package/src/components/Mindmap/editor/SelectionToolbar.vue +191 -0
  28. package/src/components/Mindmap/editor/canvasClipboard.ts +6 -0
  29. package/src/components/Mindmap/editor/imagePaste.ts +32 -0
  30. package/src/components/Mindmap/editor/mindmapClipboard.ts +93 -0
  31. package/src/components/Mindmap/editor/outlineDrag.ts +29 -0
  32. package/src/components/Mindmap/editor/platform.ts +18 -0
  33. package/src/components/Mindmap/expandLevel.ts +28 -0
  34. package/src/components/Mindmap/icons/icon__fullscreen.svg +1 -0
  35. package/src/components/Mindmap/icons/icon__fullscreen_exit.svg +1 -0
  36. package/src/components/Mindmap/icons/icon__zoom_fit.svg +1 -0
  37. package/src/components/Mindmap/markdown.ts +83 -0
  38. package/src/components/Mindmap/wheelInteraction.ts +7 -0
  39. package/src/components/NotesTable/NotesTable.vue +119 -0
  40. package/src/components/NotesTable/types.ts +6 -0
  41. package/src/components/WordList/RightClickMenu.vue +106 -0
  42. package/src/components/WordList/WordList.vue +692 -0
  43. package/src/components/WordList/wordListFeatures.ts +38 -0
  44. package/src/index.ts +30 -0
  45. package/src/styles/tokens.css +35 -0
@@ -0,0 +1,393 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import {
4
+ parseInlineSegments,
5
+ nextGraphemeOffset,
6
+ previousGraphemeOffset,
7
+ replaceInlineDisplayText,
8
+ replaceInlineRange,
9
+ richSelectionOffsets,
10
+ setRichSelection,
11
+ stripInline,
12
+ } from '@tnotesjs/mindmap-core'
13
+ import type { InlineLink, RichInlineEditorElement } from '@tnotesjs/mindmap-core'
14
+
15
+ const props = withDefaults(defineProps<{
16
+ editorId: string
17
+ raw: string
18
+ active: boolean
19
+ done?: boolean
20
+ placeholder?: string
21
+ pasteMode?: 'single-line' | 'outline'
22
+ }>(), {
23
+ done: false,
24
+ placeholder: '',
25
+ pasteMode: 'single-line',
26
+ })
27
+
28
+ interface DraftPayload {
29
+ raw: string
30
+ text: string
31
+ }
32
+
33
+ const emit = defineEmits<{
34
+ draftChange: [payload: DraftPayload]
35
+ commit: [payload: DraftPayload]
36
+ pasteImage: [blob: Blob]
37
+ pasteMultiline: [text: string]
38
+ linkEnter: [link: InlineLink, event: MouseEvent]
39
+ linkLeave: []
40
+ linkClick: [link: InlineLink, event: Event]
41
+ }>()
42
+
43
+ const rootRef = ref<RichInlineEditorElement>()
44
+ const draftRaw = ref(props.raw)
45
+ let sourceRaw = props.raw
46
+ let dirty = false
47
+ let composing = false
48
+ let compositionBaseRaw = props.raw
49
+
50
+ const text = computed(() => stripInline(draftRaw.value))
51
+
52
+ function renderInlineDom() {
53
+ const root = rootRef.value
54
+ if (!root) return
55
+ const fragment = document.createDocumentFragment()
56
+ let plainOffset = 0
57
+ for (const segment of parseInlineSegments(draftRaw.value)) {
58
+ const run = document.createElement('span')
59
+ run.className = 'inline-run'
60
+ for (const format of ['bold', 'italic', 'underline', 'strike', 'highlight', 'code'] as const) {
61
+ if (segment.marks[format]) run.classList.add(format)
62
+ }
63
+ if (segment.link) {
64
+ run.classList.add('link')
65
+ run.dataset.linkUrl = segment.link.url
66
+ run.dataset.linkRawStart = String(segment.link.rawStart)
67
+ run.dataset.linkRawEnd = String(segment.link.rawEnd)
68
+ if (!props.active) {
69
+ run.setAttribute('role', 'link')
70
+ run.tabIndex = 0
71
+ }
72
+ run.addEventListener('mouseenter', (event) => {
73
+ if (!props.active) emit('linkEnter', segment.link!, event)
74
+ })
75
+ run.addEventListener('mouseleave', () => {
76
+ if (!props.active) emit('linkLeave')
77
+ })
78
+ }
79
+ run.dataset.plainStart = String(plainOffset)
80
+ plainOffset += segment.text.length
81
+ run.dataset.plainEnd = String(plainOffset)
82
+ run.textContent = segment.text
83
+ fragment.append(run)
84
+ }
85
+ root.replaceChildren(fragment)
86
+ }
87
+
88
+ function payload(): DraftPayload {
89
+ return { raw: draftRaw.value, text: stripInline(draftRaw.value) }
90
+ }
91
+
92
+ function renderDraft(nextRaw: string, selection?: { start: number; end?: number; direction?: 'forward' | 'backward' }) {
93
+ draftRaw.value = nextRaw
94
+ dirty = nextRaw !== sourceRaw
95
+ renderInlineDom()
96
+ emit('draftChange', payload())
97
+ if (selection) {
98
+ nextTick(() => {
99
+ const root = rootRef.value
100
+ if (!root || !props.active) return
101
+ root.focus()
102
+ setRichSelection(root, selection.start, selection.end ?? selection.start, selection.direction)
103
+ })
104
+ }
105
+ }
106
+
107
+ function replaceSelection(textToInsert: string) {
108
+ const root = rootRef.value
109
+ if (!root) return
110
+ const selection = richSelectionOffsets(root) ?? {
111
+ start: text.value.length,
112
+ end: text.value.length,
113
+ anchor: text.value.length,
114
+ focus: text.value.length,
115
+ direction: 'forward' as const,
116
+ }
117
+ const nextRaw = replaceInlineRange(draftRaw.value, selection.start, selection.end, textToInsert)
118
+ const caret = selection.start + textToInsert.length
119
+ renderDraft(nextRaw, { start: caret })
120
+ }
121
+
122
+ function emitCommit() {
123
+ if (!dirty) return
124
+ const current = payload()
125
+ dirty = false
126
+ sourceRaw = current.raw
127
+ emit('commit', current)
128
+ }
129
+
130
+ function onBeforeInput(event: InputEvent) {
131
+ if (composing || event.isComposing) return
132
+ const root = rootRef.value
133
+ if (!root) return
134
+ const selection = richSelectionOffsets(root) ?? {
135
+ start: text.value.length,
136
+ end: text.value.length,
137
+ anchor: text.value.length,
138
+ focus: text.value.length,
139
+ direction: 'forward' as const,
140
+ }
141
+
142
+ if (event.inputType === 'insertText' || event.inputType === 'insertReplacementText') {
143
+ if (event.data == null) return
144
+ event.preventDefault()
145
+ const nextRaw = replaceInlineRange(draftRaw.value, selection.start, selection.end, event.data)
146
+ renderDraft(nextRaw, { start: selection.start + event.data.length })
147
+ return
148
+ }
149
+
150
+ if (event.inputType === 'deleteContentBackward') {
151
+ event.preventDefault()
152
+ const start = selection.start === selection.end
153
+ ? previousGraphemeOffset(text.value, selection.start)
154
+ : selection.start
155
+ renderDraft(replaceInlineRange(draftRaw.value, start, selection.end, ''), { start })
156
+ return
157
+ }
158
+
159
+ if (event.inputType === 'deleteContentForward') {
160
+ event.preventDefault()
161
+ const end = selection.start === selection.end
162
+ ? nextGraphemeOffset(text.value, selection.end)
163
+ : selection.end
164
+ renderDraft(replaceInlineRange(draftRaw.value, selection.start, end, ''), { start: selection.start })
165
+ return
166
+ }
167
+
168
+ if (event.inputType === 'deleteByCut') {
169
+ event.preventDefault()
170
+ renderDraft(replaceInlineRange(draftRaw.value, selection.start, selection.end, ''), { start: selection.start })
171
+ return
172
+ }
173
+
174
+ if (
175
+ event.inputType === 'insertParagraph' ||
176
+ event.inputType === 'insertLineBreak' ||
177
+ event.inputType === 'historyUndo' ||
178
+ event.inputType === 'historyRedo'
179
+ ) {
180
+ event.preventDefault()
181
+ }
182
+ }
183
+
184
+ /** 浏览器拼写替换等未被 beforeinput 精确接管的输入,回退为纯文本最小差异。 */
185
+ function onInput(event: InputEvent) {
186
+ if (composing || event.isComposing) return
187
+ const root = rootRef.value
188
+ if (!root) return
189
+ const nextText = (root.textContent ?? '').replace(/[\r\n]/g, '')
190
+ if (nextText === text.value) return
191
+ const selection = richSelectionOffsets(root)
192
+ renderDraft(replaceInlineDisplayText(draftRaw.value, nextText), selection ?? undefined)
193
+ }
194
+
195
+ function onCompositionStart() {
196
+ composing = true
197
+ compositionBaseRaw = draftRaw.value
198
+ }
199
+
200
+ function finishComposition() {
201
+ if (!composing) return
202
+ const root = rootRef.value
203
+ if (!root) return
204
+ const nextText = (root.textContent ?? '').replace(/[\r\n]/g, '')
205
+ const selection = richSelectionOffsets(root)
206
+ composing = false
207
+ renderDraft(replaceInlineDisplayText(compositionBaseRaw, nextText), selection ?? undefined)
208
+ }
209
+
210
+ function onCompositionEnd() {
211
+ finishComposition()
212
+ }
213
+
214
+ function onPaste(event: ClipboardEvent) {
215
+ event.preventDefault()
216
+ const image = [...(event.clipboardData?.items ?? [])]
217
+ .find((item) => item.kind === 'file' && item.type.startsWith('image/'))
218
+ ?.getAsFile()
219
+ if (image) {
220
+ emitCommit()
221
+ emit('pasteImage', image)
222
+ return
223
+ }
224
+ const plain = event.clipboardData?.getData('text/plain') ?? ''
225
+ if (props.pasteMode === 'outline' && /\r?\n/.test(plain)) {
226
+ emitCommit()
227
+ emit('pasteMultiline', plain)
228
+ return
229
+ }
230
+ replaceSelection(plain.replace(/\r?\n/g, ' '))
231
+ }
232
+
233
+ function onBlur() {
234
+ finishComposition()
235
+ emitCommit()
236
+ }
237
+
238
+ function onLinkClick(link: InlineLink, event: Event) {
239
+ if (props.active) return
240
+ emit('linkClick', link, event)
241
+ }
242
+
243
+ function linkFromTarget(target: EventTarget | null): { element: HTMLElement; link: InlineLink } | null {
244
+ const element = target instanceof HTMLElement ? target.closest<HTMLElement>('.inline-run.link') : null
245
+ const root = rootRef.value
246
+ if (!element || !root?.contains(element) || !element.dataset.linkUrl) return null
247
+ return {
248
+ element,
249
+ link: {
250
+ url: element.dataset.linkUrl,
251
+ rawStart: Number(element.dataset.linkRawStart ?? 0),
252
+ rawEnd: Number(element.dataset.linkRawEnd ?? 0),
253
+ },
254
+ }
255
+ }
256
+
257
+ function onMouseOver(event: MouseEvent) {
258
+ if (props.active) return
259
+ const found = linkFromTarget(event.target)
260
+ if (!found || (event.relatedTarget instanceof Node && found.element.contains(event.relatedTarget))) return
261
+ emit('linkEnter', found.link, event)
262
+ }
263
+
264
+ function onMouseOut(event: MouseEvent) {
265
+ if (props.active) return
266
+ const found = linkFromTarget(event.target)
267
+ if (!found || (event.relatedTarget instanceof Node && found.element.contains(event.relatedTarget))) return
268
+ emit('linkLeave')
269
+ }
270
+
271
+ function onClick(event: MouseEvent) {
272
+ const found = linkFromTarget(event.target)
273
+ if (found) onLinkClick(found.link, event)
274
+ }
275
+
276
+ function onRootKeydown(event: KeyboardEvent) {
277
+ if (props.active || event.key !== 'Enter') return
278
+ const found = linkFromTarget(event.target)
279
+ if (!found) return
280
+ event.preventDefault()
281
+ emit('linkClick', found.link, event)
282
+ }
283
+
284
+ function installCompatibilityApi(root: RichInlineEditorElement) {
285
+ Object.defineProperties(root, {
286
+ value: {
287
+ configurable: true,
288
+ get: () => stripInline(draftRaw.value),
289
+ set: (next: string) => {
290
+ renderDraft(replaceInlineDisplayText(draftRaw.value, String(next)))
291
+ },
292
+ },
293
+ rawValue: { configurable: true, get: () => draftRaw.value },
294
+ selectionStart: { configurable: true, get: () => richSelectionOffsets(root)?.start ?? 0 },
295
+ selectionEnd: { configurable: true, get: () => richSelectionOffsets(root)?.end ?? 0 },
296
+ isDirty: { configurable: true, get: () => dirty },
297
+ isComposing: { configurable: true, get: () => composing },
298
+ })
299
+ root.setSelectionRange = (start: number, end: number) => setRichSelection(root, start, end)
300
+ root.markCommitted = () => {
301
+ dirty = false
302
+ sourceRaw = draftRaw.value
303
+ }
304
+ }
305
+
306
+ onMounted(() => {
307
+ if (rootRef.value) {
308
+ installCompatibilityApi(rootRef.value)
309
+ renderInlineDom()
310
+ }
311
+ })
312
+
313
+ watch(
314
+ () => props.raw,
315
+ (raw) => {
316
+ sourceRaw = raw
317
+ dirty = false
318
+ if (composing) return
319
+ if (draftRaw.value !== raw) {
320
+ draftRaw.value = raw
321
+ renderInlineDom()
322
+ }
323
+ },
324
+ )
325
+
326
+ watch(
327
+ () => props.active,
328
+ (active) => {
329
+ if (!active) emitCommit()
330
+ if (!composing && !dirty) renderInlineDom()
331
+ },
332
+ )
333
+
334
+ onBeforeUnmount(emitCommit)
335
+ </script>
336
+
337
+ <template>
338
+ <div
339
+ ref="rootRef"
340
+ class="rich-inline-editor"
341
+ :class="{ active, done }"
342
+ :contenteditable="active ? 'true' : 'false'"
343
+ :data-id="editorId"
344
+ :data-empty="text.length === 0 ? 'true' : undefined"
345
+ :data-placeholder="placeholder"
346
+ :tabindex="active ? 0 : -1"
347
+ role="textbox"
348
+ aria-multiline="false"
349
+ spellcheck="false"
350
+ @beforeinput="onBeforeInput"
351
+ @input="onInput"
352
+ @compositionstart="onCompositionStart"
353
+ @compositionend="onCompositionEnd"
354
+ @paste="onPaste"
355
+ @blur="onBlur"
356
+ @mouseover="onMouseOver"
357
+ @mouseout="onMouseOut"
358
+ @click="onClick"
359
+ @keydown="onRootKeydown"
360
+ />
361
+ </template>
362
+
363
+ <style scoped>
364
+ .rich-inline-editor {
365
+ white-space: pre-wrap;
366
+ overflow-wrap: anywhere;
367
+ caret-color: var(--mm-accent, var(--mm-text, #3b82f6));
368
+ }
369
+ .rich-inline-editor[data-empty='true']::before {
370
+ content: attr(data-placeholder);
371
+ color: var(--mm-text-dim);
372
+ pointer-events: none;
373
+ }
374
+ .rich-inline-editor.active { cursor: text; }
375
+ .rich-inline-editor :deep(.inline-run.bold) { font-weight: 700; }
376
+ .rich-inline-editor :deep(.inline-run.italic) { font-style: italic; }
377
+ .rich-inline-editor :deep(.inline-run.underline) { text-decoration-line: underline; text-underline-offset: 3px; }
378
+ .rich-inline-editor :deep(.inline-run.strike) { text-decoration-line: line-through; }
379
+ .rich-inline-editor :deep(.inline-run.underline.strike) { text-decoration-line: underline line-through; }
380
+ .rich-inline-editor :deep(.inline-run.highlight:not(.code)) { padding: 0 1px; border-radius: 2px; background: #fff36a; color: #242424; }
381
+ .rich-inline-editor :deep(.inline-run.code) {
382
+ padding: 2px 6px;
383
+ border-radius: 5px;
384
+ background: color-mix(in srgb, var(--mm-text) 18%, transparent);
385
+ color: #f08a6e;
386
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
387
+ font-size: .92em;
388
+ box-decoration-break: clone;
389
+ -webkit-box-decoration-break: clone;
390
+ }
391
+ .rich-inline-editor :deep(.inline-run.link) { color: var(--mm-accent); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
392
+ .rich-inline-editor.done { color: var(--mm-text-dim); text-decoration: line-through; }
393
+ </style>
@@ -0,0 +1,191 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import AppIcon from './AppIcon.vue'
4
+ import type { InlineFormat } from '@tnotesjs/mindmap-core'
5
+ import { altShortcut, primaryShortcut } from './platform'
6
+
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ mode: 'text' | 'nodes'
10
+ /** Viewport coords for `fixed` placement (outline / text selection). */
11
+ position?: { left: number; top: number }
12
+ /**
13
+ * `canvas-bottom`: pin to the mindmap canvas (absolute) so page scroll cannot
14
+ * leave a floating orphan over foreign content.
15
+ * `fixed`: follow `position` in viewport space (outline / text carets).
16
+ */
17
+ placement?: 'fixed' | 'canvas-bottom'
18
+ activeFormats?: Partial<Record<InlineFormat, boolean>>
19
+ }>(),
20
+ { placement: 'fixed' },
21
+ )
22
+
23
+ const emit = defineEmits<{
24
+ format: [format: InlineFormat]
25
+ task: []
26
+ image: []
27
+ link: []
28
+ copy: []
29
+ clear: []
30
+ delete: []
31
+ }>()
32
+
33
+ const formats: Array<{ id: InlineFormat; text: string; label: string }> = [
34
+ { id: 'bold', text: 'B', label: `加粗 (${primaryShortcut('B')})` },
35
+ { id: 'italic', text: 'I', label: `斜体 (${primaryShortcut('I')})` },
36
+ { id: 'underline', text: 'U', label: `下划线 (${primaryShortcut('U')})` },
37
+ { id: 'strike', text: 'S', label: `删除线 (${primaryShortcut('Enter')})` },
38
+ { id: 'highlight', text: '▰', label: '高亮' },
39
+ ]
40
+
41
+ const toolbarRef = ref<HTMLElement | null>(null)
42
+ const toolbarSize = ref({ width: 430, height: 46 })
43
+ const VIEWPORT_MARGIN = 8
44
+ const ANCHOR_GAP = 8
45
+
46
+ const toolbarStyle = computed(() => {
47
+ if (props.placement === 'canvas-bottom') return undefined
48
+ const point = props.position
49
+ if (!point) return undefined
50
+ const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth
51
+ const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight
52
+ const { width, height } = toolbarSize.value
53
+ const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
54
+ const maxTop = Math.max(VIEWPORT_MARGIN, viewportHeight - height - VIEWPORT_MARGIN)
55
+ const left = Math.min(maxLeft, Math.max(VIEWPORT_MARGIN, point.left - width / 2))
56
+ const preferredTop = point.top - height - ANCHOR_GAP
57
+ const belowTop = point.top + ANCHOR_GAP
58
+ const top = Math.min(maxTop, Math.max(VIEWPORT_MARGIN, preferredTop < VIEWPORT_MARGIN ? belowTop : preferredTop))
59
+ return { left: `${left}px`, top: `${top}px` }
60
+ })
61
+
62
+ function measureToolbar() {
63
+ nextTick(() => {
64
+ const rect = toolbarRef.value?.getBoundingClientRect()
65
+ if (!rect || rect.width <= 0 || rect.height <= 0) return
66
+ toolbarSize.value = { width: rect.width, height: rect.height }
67
+ })
68
+ }
69
+
70
+ watch(
71
+ () => [props.mode, props.placement, props.position?.left, props.position?.top],
72
+ measureToolbar,
73
+ )
74
+ onMounted(() => {
75
+ measureToolbar()
76
+ window.addEventListener('resize', measureToolbar)
77
+ })
78
+ onBeforeUnmount(() => window.removeEventListener('resize', measureToolbar))
79
+ </script>
80
+
81
+ <template>
82
+ <div
83
+ ref="toolbarRef"
84
+ class="selection-toolbar"
85
+ :class="{ 'is-canvas-anchored': placement === 'canvas-bottom' }"
86
+ :style="toolbarStyle"
87
+ role="toolbar"
88
+ :aria-label="mode === 'text' ? '文字格式工具栏' : '多主题工具栏'"
89
+ @pointerdown.prevent
90
+ >
91
+ <button
92
+ v-for="item in formats"
93
+ :key="item.id"
94
+ type="button"
95
+ class="format-button"
96
+ :class="[{ active: activeFormats?.[item.id] }, `is-${item.id}`]"
97
+ :data-tooltip="item.label"
98
+ :aria-label="item.label"
99
+ @click="emit('format', item.id)"
100
+ >
101
+ <AppIcon v-if="item.id === 'highlight'" name="highlight" :size="21" />
102
+ <template v-else>{{ item.text }}</template>
103
+ </button>
104
+ <span class="toolbar-divider" />
105
+ <button type="button" class="tool-button" :data-tooltip="`添加/取消待办 (${primaryShortcut('L', { shift: true })})`" aria-label="添加或取消待办" @click="emit('task')">
106
+ <AppIcon name="check" :size="20" />
107
+ </button>
108
+ <template v-if="mode === 'text'">
109
+ <button type="button" class="tool-button" :data-tooltip="`添加图片 (${altShortcut('Enter')})`" aria-label="添加图片" @click="emit('image')"><AppIcon name="image" :size="20" /></button>
110
+ <button type="button" class="tool-button" :data-tooltip="`添加链接 (${primaryShortcut('K')})`" aria-label="添加链接" @click="emit('link')"><AppIcon name="link" :size="20" /></button>
111
+ <button type="button" class="tool-button code-button" :data-tooltip="`行内代码 (${primaryShortcut('E')})`" aria-label="行内代码" @click="emit('format', 'code')">&lt;/&gt;</button>
112
+ </template>
113
+ <button v-else type="button" class="tool-button" :data-tooltip="`复制 (${primaryShortcut('C')})`" aria-label="复制所选主题" @click="emit('copy')"><AppIcon name="copy" :size="20" /></button>
114
+ <span class="toolbar-divider" />
115
+ <button type="button" class="tool-button" :data-tooltip="`清除样式 (${primaryShortcut('\\')})`" aria-label="清除样式" @click="emit('clear')"><AppIcon name="clearFormat" :size="20" /></button>
116
+ <button type="button" class="tool-button danger" :data-tooltip="`删除 (${primaryShortcut('D', { shift: true })})`" aria-label="删除" @click="emit('delete')"><AppIcon name="trash" :size="20" /></button>
117
+ </div>
118
+ </template>
119
+
120
+ <style scoped>
121
+ .selection-toolbar {
122
+ position: fixed;
123
+ z-index: 120;
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 2px;
127
+ min-height: 46px;
128
+ padding: 5px 7px;
129
+ border: 1px solid color-mix(in srgb, var(--mm-border) 85%, transparent);
130
+ border-radius: 11px;
131
+ background: color-mix(in srgb, var(--mm-panel-bg) 94%, #545760 6%);
132
+ color: var(--mm-text);
133
+ box-shadow: 0 10px 30px rgb(0 0 0 / .24);
134
+ }
135
+ .selection-toolbar.is-canvas-anchored {
136
+ position: absolute;
137
+ left: 50%;
138
+ right: auto;
139
+ top: auto;
140
+ bottom: 18px;
141
+ transform: translateX(-50%);
142
+ }
143
+ .format-button, .tool-button {
144
+ position: relative;
145
+ display: inline-flex;
146
+ width: 34px;
147
+ height: 34px;
148
+ align-items: center;
149
+ justify-content: center;
150
+ border: 0;
151
+ border-radius: 6px;
152
+ background: transparent;
153
+ color: inherit;
154
+ cursor: pointer;
155
+ font-size: 21px;
156
+ }
157
+ .format-button::after, .tool-button::after {
158
+ position: absolute;
159
+ z-index: 2;
160
+ bottom: calc(100% + 9px);
161
+ left: 50%;
162
+ width: max-content;
163
+ max-width: min(260px, calc(100vw - 16px));
164
+ padding: 7px 10px;
165
+ border-radius: 7px;
166
+ background: #f5f5f7;
167
+ color: #25262b;
168
+ box-shadow: 0 5px 18px rgb(0 0 0 / .22);
169
+ content: attr(data-tooltip);
170
+ font-family: system-ui, sans-serif;
171
+ font-size: 13px;
172
+ font-style: normal;
173
+ font-weight: 500;
174
+ line-height: 1.2;
175
+ opacity: 0;
176
+ pointer-events: none;
177
+ transform: translateX(-50%);
178
+ white-space: nowrap;
179
+ }
180
+ .format-button:hover::after, .tool-button:hover::after,
181
+ .format-button:focus-visible::after, .tool-button:focus-visible::after { opacity: 1; }
182
+ .format-button:hover, .tool-button:hover, .format-button.active { background: var(--mm-hover); color: var(--mm-accent); }
183
+ .format-button.is-bold { font-weight: 800; }
184
+ .format-button.is-italic { font-family: Georgia, serif; font-style: italic; }
185
+ .format-button.is-underline { text-decoration: underline; text-underline-offset: 4px; }
186
+ .format-button.is-strike { text-decoration: line-through; }
187
+ .format-button.is-highlight { color: #d3c900; }
188
+ .tool-button.code-button { font-family: ui-monospace, monospace; font-size: 15px; font-weight: 700; }
189
+ .tool-button.danger { color: #e14f5b; }
190
+ .toolbar-divider { width: 1px; height: 25px; margin: 0 3px; background: var(--mm-border); }
191
+ </style>
@@ -0,0 +1,6 @@
1
+ /** @deprecated Import from `./mindmapClipboard` — kept so Vite stale graphs do not 404. */
2
+ export {
3
+ pasteCanvasOutline,
4
+ readMindmapClipboard,
5
+ writeMindmapClipboard,
6
+ } from './mindmapClipboard'
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 源码视图只支持“整节点图片”,不支持把图片嵌进普通文字。
3
+ * 空列表项会在原位变成图片;其它位置统一追加一个顶级图片节点,避免破坏树层级。
4
+ */
5
+ export function insertImageIntoSource(
6
+ markdown: string,
7
+ selectionStart: number,
8
+ selectionEnd: number,
9
+ relativePath: string,
10
+ alt = '截图',
11
+ ): string {
12
+ const image = `![${alt.replace(/\]/g, '').trim() || '截图'}](${relativePath})`
13
+ const start = Math.max(0, Math.min(markdown.length, selectionStart))
14
+ const end = Math.max(start, Math.min(markdown.length, selectionEnd))
15
+ const lineStart = markdown.lastIndexOf('\n', start - 1) + 1
16
+ const nextBreak = markdown.indexOf('\n', end)
17
+ const lineEnd = nextBreak < 0 ? markdown.length : nextBreak
18
+ const line = markdown.slice(lineStart, lineEnd)
19
+ const list = /^(\s*[-*+]\s+)(.*)$/.exec(line)
20
+
21
+ if (list) {
22
+ const contentStart = lineStart + list[1].length
23
+ const contentEnd = lineEnd
24
+ const replacesWholeContent = start <= contentStart && end >= contentEnd
25
+ if (list[2].trim() === '' || replacesWholeContent) {
26
+ return `${markdown.slice(0, contentStart)}${image}${markdown.slice(contentEnd)}`
27
+ }
28
+ }
29
+
30
+ const body = markdown.trimEnd()
31
+ return `${body}${body ? '\n\n' : ''}- ${image}\n`
32
+ }