@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +68 -0
- package/src/components/BilibiliVideo/BilibiliVideo.vue +55 -0
- package/src/components/Footprints/Footprints.vue +339 -0
- package/src/components/Footprints/parse.ts +122 -0
- package/src/components/Mermaid/Mermaid.vue +553 -0
- package/src/components/Mermaid/icons/icon__center_off.svg +1 -0
- package/src/components/Mermaid/icons/icon__center_on.svg +1 -0
- package/src/components/Mermaid/icons/icon__check.svg +3 -0
- package/src/components/Mermaid/icons/icon__clipboard.svg +8 -0
- package/src/components/Mermaid/icons/icon__fullscreen.svg +1 -0
- package/src/components/Mermaid/icons/icon__fullscreen_exit.svg +1 -0
- package/src/components/Mindmap/FocusBreadcrumbs.test.ts +265 -0
- package/src/components/Mindmap/FocusBreadcrumbs.vue +436 -0
- package/src/components/Mindmap/InlineRuns.ts +25 -0
- package/src/components/Mindmap/Mindmap.vue +1210 -0
- package/src/components/Mindmap/MindmapOutlineNode.vue +62 -0
- package/src/components/Mindmap/MindmapViewIcon.vue +41 -0
- package/src/components/Mindmap/editor/AppIcon.vue +107 -0
- package/src/components/Mindmap/editor/CanvasContextMenu.vue +157 -0
- package/src/components/Mindmap/editor/LinkPopover.vue +83 -0
- package/src/components/Mindmap/editor/MarkdownView.vue +163 -0
- package/src/components/Mindmap/editor/MindmapView.vue +253 -0
- package/src/components/Mindmap/editor/OutlineView.vue +2494 -0
- package/src/components/Mindmap/editor/RichInlineEditor.vue +393 -0
- package/src/components/Mindmap/editor/SelectionToolbar.vue +191 -0
- package/src/components/Mindmap/editor/canvasClipboard.ts +6 -0
- package/src/components/Mindmap/editor/imagePaste.ts +32 -0
- package/src/components/Mindmap/editor/mindmapClipboard.ts +93 -0
- package/src/components/Mindmap/editor/outlineDrag.ts +29 -0
- package/src/components/Mindmap/editor/platform.ts +18 -0
- package/src/components/Mindmap/expandLevel.ts +28 -0
- package/src/components/Mindmap/icons/icon__fullscreen.svg +1 -0
- package/src/components/Mindmap/icons/icon__fullscreen_exit.svg +1 -0
- package/src/components/Mindmap/icons/icon__zoom_fit.svg +1 -0
- package/src/components/Mindmap/markdown.ts +83 -0
- package/src/components/Mindmap/wheelInteraction.ts +7 -0
- package/src/components/NotesTable/NotesTable.vue +119 -0
- package/src/components/NotesTable/types.ts +6 -0
- package/src/components/WordList/RightClickMenu.vue +106 -0
- package/src/components/WordList/WordList.vue +692 -0
- package/src/components/WordList/wordListFeatures.ts +38 -0
- package/src/index.ts +30 -0
- package/src/styles/tokens.css +35 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import InlineRuns from './InlineRuns'
|
|
3
|
+
|
|
4
|
+
import type { MindmapNode } from '@tnotesjs/mindmap-core'
|
|
5
|
+
|
|
6
|
+
defineOptions({ name: 'MindmapOutlineNode' })
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
node: MindmapNode
|
|
10
|
+
version: number
|
|
11
|
+
root?: boolean
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
defineEmits<{
|
|
15
|
+
toggle: [id: string]
|
|
16
|
+
}>()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<li class="mindmap-outline-node" :class="{ 'is-root': root, 'is-done': node.content.checked === true }">
|
|
21
|
+
<div class="mindmap-outline-row">
|
|
22
|
+
<button
|
|
23
|
+
v-if="node.children.length > 0"
|
|
24
|
+
type="button"
|
|
25
|
+
class="mindmap-outline-toggle"
|
|
26
|
+
:aria-label="node.collapsed ? '展开主题' : '折叠主题'"
|
|
27
|
+
:aria-expanded="!node.collapsed"
|
|
28
|
+
@click="$emit('toggle', node.id)"
|
|
29
|
+
>
|
|
30
|
+
{{ node.collapsed ? '›' : '⌄' }}
|
|
31
|
+
</button>
|
|
32
|
+
<span v-else class="mindmap-outline-leaf" aria-hidden="true">•</span>
|
|
33
|
+
<input
|
|
34
|
+
v-if="node.content.checked !== null && !root"
|
|
35
|
+
class="mindmap-outline-checkbox"
|
|
36
|
+
type="checkbox"
|
|
37
|
+
:checked="node.content.checked"
|
|
38
|
+
disabled
|
|
39
|
+
aria-label="只读待办状态"
|
|
40
|
+
/>
|
|
41
|
+
<span class="mindmap-outline-label">
|
|
42
|
+
<InlineRuns :raw="node.content.image ? node.content.text : node.content.raw" />
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
<img
|
|
46
|
+
v-if="node.content.image"
|
|
47
|
+
class="mindmap-outline-image"
|
|
48
|
+
:src="node.content.image.src"
|
|
49
|
+
:alt="node.content.image.alt"
|
|
50
|
+
:style="node.content.image.width ? { width: `${node.content.image.width}px` } : undefined"
|
|
51
|
+
/>
|
|
52
|
+
<ul v-if="node.children.length > 0 && !node.collapsed" class="mindmap-outline-children">
|
|
53
|
+
<MindmapOutlineNode
|
|
54
|
+
v-for="child in node.children"
|
|
55
|
+
:key="child.id"
|
|
56
|
+
:node="child"
|
|
57
|
+
:version="version"
|
|
58
|
+
@toggle="$emit('toggle', $event)"
|
|
59
|
+
/>
|
|
60
|
+
</ul>
|
|
61
|
+
</li>
|
|
62
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
view: 'mindmap' | 'outline' | 'source'
|
|
4
|
+
}>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<svg v-if="view === 'mindmap'" aria-hidden="true" viewBox="0 0 48 48">
|
|
9
|
+
<path d="M0 0h48v48H0z" fill="none" />
|
|
10
|
+
<path
|
|
11
|
+
d="M26 24h16M26 38h16M26 10h16M18 24H6h4m8 14c-6-2-2-14-8-14m8-14c-6 2-2 14-8 14"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="#636cff"
|
|
14
|
+
stroke-linecap="round"
|
|
15
|
+
stroke-linejoin="round"
|
|
16
|
+
stroke-width="4"
|
|
17
|
+
/>
|
|
18
|
+
</svg>
|
|
19
|
+
<svg v-else-if="view === 'outline'" aria-hidden="true" viewBox="0 0 48 48">
|
|
20
|
+
<path d="M0 0h48v48H0z" fill="none" />
|
|
21
|
+
<path
|
|
22
|
+
d="M26 24h18m-30 0h4m0 14h26M6 38h4m8-28h26M6 10h4"
|
|
23
|
+
fill="none"
|
|
24
|
+
stroke="#636cff"
|
|
25
|
+
stroke-linecap="round"
|
|
26
|
+
stroke-linejoin="round"
|
|
27
|
+
stroke-width="4"
|
|
28
|
+
/>
|
|
29
|
+
</svg>
|
|
30
|
+
<svg v-else aria-hidden="true" viewBox="0 0 24 24">
|
|
31
|
+
<path d="M0 0h24v24H0z" fill="none" />
|
|
32
|
+
<path
|
|
33
|
+
d="m17 8 1.84 1.85c.773.778 1.16 1.167 1.16 1.65s-.387.872-1.16 1.65L17 15M7 8 5.16 9.85C4.387 10.628 4 11.017 4 11.5s.387.872 1.16 1.65L7 15m7.5-11-5 16"
|
|
34
|
+
fill="none"
|
|
35
|
+
stroke="#636cff"
|
|
36
|
+
stroke-linecap="round"
|
|
37
|
+
stroke-linejoin="round"
|
|
38
|
+
stroke-width="1.5"
|
|
39
|
+
/>
|
|
40
|
+
</svg>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ name: string; size?: number }>()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<svg
|
|
7
|
+
class="app-icon"
|
|
8
|
+
:width="size ?? 18"
|
|
9
|
+
:height="size ?? 18"
|
|
10
|
+
viewBox="0 0 24 24"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
stroke-width="1.8"
|
|
14
|
+
stroke-linecap="round"
|
|
15
|
+
stroke-linejoin="round"
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
>
|
|
18
|
+
<template v-if="name === 'menu'">
|
|
19
|
+
<path d="M4 6h16M4 12h16M4 18h16" />
|
|
20
|
+
</template>
|
|
21
|
+
<template v-else-if="name === 'new'">
|
|
22
|
+
<path d="M6 3h9l3 3v15H6zM14 3v4h4M12 10v7M8.5 13.5h7" />
|
|
23
|
+
</template>
|
|
24
|
+
<template v-else-if="name === 'folder' || name === 'openProject'">
|
|
25
|
+
<path d="M3 6h7l2 2h9v11H3z" />
|
|
26
|
+
<path v-if="name === 'openProject'" d="m10 15 2 2 4-4" />
|
|
27
|
+
</template>
|
|
28
|
+
<template v-else-if="name === 'save'">
|
|
29
|
+
<path d="M5 3h12l2 2v16H5zM8 3v6h8V3M8 15h8" />
|
|
30
|
+
</template>
|
|
31
|
+
<template v-else-if="name === 'download'">
|
|
32
|
+
<path d="M12 3v12m-4-4 4 4 4-4M4 19h16" />
|
|
33
|
+
</template>
|
|
34
|
+
<template v-else-if="name === 'undo'">
|
|
35
|
+
<path d="m9 7-5 5 5 5M4 12h9a6 6 0 0 1 6 6" />
|
|
36
|
+
</template>
|
|
37
|
+
<template v-else-if="name === 'redo'">
|
|
38
|
+
<path d="m15 7 5 5-5 5M20 12h-9a6 6 0 0 0-6 6" />
|
|
39
|
+
</template>
|
|
40
|
+
<template v-else-if="name === 'search'">
|
|
41
|
+
<circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5" />
|
|
42
|
+
</template>
|
|
43
|
+
<template v-else-if="name === 'collapse'">
|
|
44
|
+
<path d="M5 5h6v6H5zM13 13h6v6h-6zM8 11v5h5M15 3v7m-3-3 3 3 3-3" />
|
|
45
|
+
</template>
|
|
46
|
+
<template v-else-if="name === 'focus'">
|
|
47
|
+
<path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" />
|
|
48
|
+
<circle cx="12" cy="12" r="3" />
|
|
49
|
+
</template>
|
|
50
|
+
<template v-else-if="name === 'zoomIn' || name === 'zoomOut'">
|
|
51
|
+
<circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5M7.5 10.5h6" />
|
|
52
|
+
<path v-if="name === 'zoomIn'" d="M10.5 7.5v6" />
|
|
53
|
+
</template>
|
|
54
|
+
<template v-else-if="name === 'fit'">
|
|
55
|
+
<path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5M8 8h8v8H8z" />
|
|
56
|
+
</template>
|
|
57
|
+
<template v-else-if="name === 'more'">
|
|
58
|
+
<circle cx="5" cy="12" r="1" fill="currentColor" /><circle cx="12" cy="12" r="1" fill="currentColor" /><circle cx="19" cy="12" r="1" fill="currentColor" />
|
|
59
|
+
</template>
|
|
60
|
+
<template v-else-if="name === 'image'">
|
|
61
|
+
<rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="9" cy="9" r="2" /><path d="m4 17 5-5 3 3 2-2 6 6" />
|
|
62
|
+
</template>
|
|
63
|
+
<template v-else-if="name === 'highlight'">
|
|
64
|
+
<path d="m14.5 4.5 5 5-8.2 8.2-5.6.6.6-5.6zM12.5 6.5l5 5M4 21h16" />
|
|
65
|
+
</template>
|
|
66
|
+
<template v-else-if="name === 'link'">
|
|
67
|
+
<path 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" />
|
|
68
|
+
</template>
|
|
69
|
+
<template v-else-if="name === 'code'">
|
|
70
|
+
<path d="m8 5-5 7 5 7M16 5l5 7-5 7M14 3l-4 18" />
|
|
71
|
+
</template>
|
|
72
|
+
<template v-else-if="name === 'trash'">
|
|
73
|
+
<path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6" />
|
|
74
|
+
</template>
|
|
75
|
+
<template v-else-if="name === 'copy'">
|
|
76
|
+
<rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
|
|
77
|
+
</template>
|
|
78
|
+
<template v-else-if="name === 'check'">
|
|
79
|
+
<rect x="3" y="3" width="18" height="18" rx="3" /><path d="m7 12 3 3 7-7" />
|
|
80
|
+
</template>
|
|
81
|
+
<template v-else-if="name === 'removeLink'">
|
|
82
|
+
<path d="M9 15 7.5 16.5a5 5 0 0 1-7-7L3 7M15 9l1.5-1.5a5 5 0 0 1 7 7L21 17M3 3l18 18" />
|
|
83
|
+
</template>
|
|
84
|
+
<template v-else-if="name === 'edit'">
|
|
85
|
+
<path d="m4 20 4.2-1 10.6-10.6a2.1 2.1 0 0 0-3-3L5.2 16zM14.8 6.2l3 3" />
|
|
86
|
+
</template>
|
|
87
|
+
<template v-else-if="name === 'clearFormat'">
|
|
88
|
+
<path d="m4 17 7.5-11.5a2 2 0 0 1 2.8-.5l4.2 2.8a2 2 0 0 1 .5 2.8L12.2 21H7zM8.5 14.5l5.8 3.8M3 21h18" />
|
|
89
|
+
</template>
|
|
90
|
+
<template v-else-if="name === 'close'">
|
|
91
|
+
<path d="M5 5l14 14M19 5 5 19" />
|
|
92
|
+
</template>
|
|
93
|
+
<template v-else-if="name === 'outline'">
|
|
94
|
+
<circle cx="5" cy="6" r="1.2" fill="currentColor" /><circle cx="5" cy="12" r="1.2" fill="currentColor" /><circle cx="5" cy="18" r="1.2" fill="currentColor" /><path d="M9 6h11M9 12h8M9 18h10" />
|
|
95
|
+
</template>
|
|
96
|
+
<template v-else-if="name === 'mindmap'">
|
|
97
|
+
<circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="18" r="2.5" /><circle cx="19" cy="18" r="2.5" /><path d="M12 7.5V12M5 15.5V12h14v3.5" />
|
|
98
|
+
</template>
|
|
99
|
+
<template v-else-if="name === 'source'">
|
|
100
|
+
<path d="m9 6-6 6 6 6M15 6l6 6-6 6M14 3l-4 18" />
|
|
101
|
+
</template>
|
|
102
|
+
</svg>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<style scoped>
|
|
106
|
+
.app-icon { display: block; flex: none; }
|
|
107
|
+
</style>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
3
|
+
import { isApplePlatform, primaryShortcut } from './platform'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(defineProps<{
|
|
6
|
+
position: { left: number; top: number }
|
|
7
|
+
multiple?: boolean
|
|
8
|
+
canInsertSibling?: boolean
|
|
9
|
+
canInsertParent?: boolean
|
|
10
|
+
canCut?: boolean
|
|
11
|
+
canDuplicate?: boolean
|
|
12
|
+
canDeleteOnly?: boolean
|
|
13
|
+
canDeleteTree?: boolean
|
|
14
|
+
canToggleSiblings?: boolean
|
|
15
|
+
canFocus?: boolean
|
|
16
|
+
}>(), {
|
|
17
|
+
multiple: false,
|
|
18
|
+
canInsertSibling: true,
|
|
19
|
+
canInsertParent: true,
|
|
20
|
+
canCut: true,
|
|
21
|
+
canDuplicate: true,
|
|
22
|
+
canDeleteOnly: true,
|
|
23
|
+
canDeleteTree: true,
|
|
24
|
+
canToggleSiblings: true,
|
|
25
|
+
canFocus: true,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{
|
|
29
|
+
close: []
|
|
30
|
+
insertSibling: []
|
|
31
|
+
insertChild: []
|
|
32
|
+
insertParent: []
|
|
33
|
+
copy: []
|
|
34
|
+
cut: []
|
|
35
|
+
paste: []
|
|
36
|
+
duplicate: []
|
|
37
|
+
deleteOnly: []
|
|
38
|
+
deleteTree: []
|
|
39
|
+
toggleSiblings: []
|
|
40
|
+
focus: []
|
|
41
|
+
}>()
|
|
42
|
+
|
|
43
|
+
const menu = ref<HTMLElement>()
|
|
44
|
+
const style = computed(() => {
|
|
45
|
+
const width = 310
|
|
46
|
+
const estimatedHeight = props.multiple ? 250 : 570
|
|
47
|
+
const viewportWidth = typeof window === 'undefined' ? 1440 : window.innerWidth
|
|
48
|
+
const viewportHeight = typeof window === 'undefined' ? 900 : window.innerHeight
|
|
49
|
+
return {
|
|
50
|
+
left: `${Math.max(8, Math.min(props.position.left, viewportWidth - width - 8))}px`,
|
|
51
|
+
top: `${Math.max(8, Math.min(props.position.top, viewportHeight - estimatedHeight - 8))}px`,
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
const shiftTab = computed(() => isApplePlatform() ? '⇧Tab' : 'Shift+Tab')
|
|
55
|
+
|
|
56
|
+
type MenuAction =
|
|
57
|
+
| 'close'
|
|
58
|
+
| 'insertSibling'
|
|
59
|
+
| 'insertChild'
|
|
60
|
+
| 'insertParent'
|
|
61
|
+
| 'copy'
|
|
62
|
+
| 'cut'
|
|
63
|
+
| 'paste'
|
|
64
|
+
| 'duplicate'
|
|
65
|
+
| 'deleteOnly'
|
|
66
|
+
| 'deleteTree'
|
|
67
|
+
| 'toggleSiblings'
|
|
68
|
+
| 'focus'
|
|
69
|
+
|
|
70
|
+
function act(event: MenuAction) {
|
|
71
|
+
// Vue 的 emit 类型不能从 keyof 精确缩窄,这里统一分发无参数菜单动作。
|
|
72
|
+
;(emit as (name: string) => void)(event)
|
|
73
|
+
if (event !== 'close') emit('close')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onPointerDown(event: PointerEvent) {
|
|
77
|
+
if (!menu.value?.contains(event.target as Node)) emit('close')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onKeydown(event: KeyboardEvent) {
|
|
81
|
+
if (event.key === 'Escape') emit('close')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onMounted(() => {
|
|
85
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
86
|
+
document.addEventListener('keydown', onKeydown)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
onBeforeUnmount(() => {
|
|
90
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
91
|
+
document.removeEventListener('keydown', onKeydown)
|
|
92
|
+
})
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<div ref="menu" class="canvas-context-menu" :style="style" role="menu" :aria-label="multiple ? '多主题右键菜单' : '主题右键菜单'" @contextmenu.prevent>
|
|
97
|
+
<template v-if="!multiple">
|
|
98
|
+
<button type="button" role="menuitem" :disabled="!canInsertSibling" @click="act('insertSibling')"><span>插入同级主题</span><kbd>Enter</kbd></button>
|
|
99
|
+
<button type="button" role="menuitem" @click="act('insertChild')"><span>插入下级主题</span><kbd>Tab</kbd></button>
|
|
100
|
+
<button type="button" role="menuitem" :disabled="!canInsertParent" @click="act('insertParent')"><span>插入上级主题</span><kbd>{{ shiftTab }}</kbd></button>
|
|
101
|
+
<span class="context-divider" />
|
|
102
|
+
</template>
|
|
103
|
+
|
|
104
|
+
<button type="button" role="menuitem" @click="act('copy')"><span>复制</span><kbd>{{ primaryShortcut('C') }}</kbd></button>
|
|
105
|
+
<button type="button" role="menuitem" :disabled="!canCut" @click="act('cut')"><span>剪切</span><kbd>{{ primaryShortcut('X') }}</kbd></button>
|
|
106
|
+
|
|
107
|
+
<template v-if="!multiple">
|
|
108
|
+
<button type="button" role="menuitem" @click="act('paste')"><span>粘贴</span><kbd>{{ primaryShortcut('V') }}</kbd></button>
|
|
109
|
+
<button type="button" role="menuitem" :disabled="!canDuplicate" @click="act('duplicate')"><span>创建副本</span><kbd>{{ primaryShortcut('D') }}</kbd></button>
|
|
110
|
+
<button type="button" role="menuitem" :disabled="!canDeleteOnly" @click="act('deleteOnly')"><span>仅删除当前主题</span></button>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<button type="button" role="menuitem" class="danger" :disabled="!canDeleteTree" @click="act('deleteTree')"><span>{{ multiple ? '删除所选主题及下级主题' : '删除当前主题及下级主题' }}</span><kbd>Delete</kbd></button>
|
|
114
|
+
<span class="context-divider" />
|
|
115
|
+
<button type="button" role="menuitem" :disabled="!canToggleSiblings" @click="act('toggleSiblings')"><span>展开/折叠同级主题</span><kbd>{{ primaryShortcut('.', { shift: true }) }}</kbd></button>
|
|
116
|
+
<button v-if="!multiple" type="button" role="menuitem" :disabled="!canFocus" @click="act('focus')"><span>进入此主题</span><kbd>{{ primaryShortcut(']') }}</kbd></button>
|
|
117
|
+
</div>
|
|
118
|
+
</template>
|
|
119
|
+
|
|
120
|
+
<style scoped>
|
|
121
|
+
.canvas-context-menu {
|
|
122
|
+
position: fixed;
|
|
123
|
+
z-index: 150;
|
|
124
|
+
display: grid;
|
|
125
|
+
width: 310px;
|
|
126
|
+
max-height: calc(100vh - 16px);
|
|
127
|
+
overflow: auto;
|
|
128
|
+
padding: 7px;
|
|
129
|
+
border: 1px solid var(--mm-border);
|
|
130
|
+
border-radius: 10px;
|
|
131
|
+
background: color-mix(in srgb, var(--mm-panel-bg) 96%, #3e424a 4%);
|
|
132
|
+
color: var(--mm-text);
|
|
133
|
+
box-shadow: 0 14px 42px rgb(0 0 0 / .28);
|
|
134
|
+
}
|
|
135
|
+
.canvas-context-menu button {
|
|
136
|
+
display: flex;
|
|
137
|
+
min-height: 39px;
|
|
138
|
+
align-items: center;
|
|
139
|
+
justify-content: space-between;
|
|
140
|
+
gap: 16px;
|
|
141
|
+
padding: 0 10px;
|
|
142
|
+
border: 0;
|
|
143
|
+
border-radius: 6px;
|
|
144
|
+
background: transparent;
|
|
145
|
+
color: inherit;
|
|
146
|
+
font: inherit;
|
|
147
|
+
font-size: 13px;
|
|
148
|
+
text-align: left;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
}
|
|
151
|
+
.canvas-context-menu button:hover:not(:disabled),
|
|
152
|
+
.canvas-context-menu button:focus-visible { background: var(--mm-hover); outline: none; }
|
|
153
|
+
.canvas-context-menu button:disabled { cursor: default; opacity: .38; }
|
|
154
|
+
.canvas-context-menu button.danger { color: #e25a62; }
|
|
155
|
+
.canvas-context-menu kbd { flex: none; color: var(--mm-text-dim); font: inherit; font-size: 12px; }
|
|
156
|
+
.context-divider { height: 1px; margin: 6px 2px; background: var(--mm-border); }
|
|
157
|
+
</style>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { nextTick, ref, watch } from 'vue'
|
|
3
|
+
import AppIcon from './AppIcon.vue'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(defineProps<{
|
|
6
|
+
url: string
|
|
7
|
+
position: { left: number; top: number }
|
|
8
|
+
startEditing?: boolean
|
|
9
|
+
}>(), { startEditing: false })
|
|
10
|
+
const emit = defineEmits<{ save: [url: string]; remove: []; keep: []; leave: []; close: [] }>()
|
|
11
|
+
const draft = ref(props.url)
|
|
12
|
+
const editing = ref(props.startEditing)
|
|
13
|
+
const input = ref<HTMLInputElement>()
|
|
14
|
+
|
|
15
|
+
watch([() => props.url, () => props.startEditing], ([url, startEditing]) => {
|
|
16
|
+
draft.value = url
|
|
17
|
+
editing.value = startEditing
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function submit() {
|
|
21
|
+
const value = draft.value.trim()
|
|
22
|
+
if (value) emit('save', value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function onKeydown(event: KeyboardEvent) {
|
|
26
|
+
if (event.key === 'Enter') {
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
submit()
|
|
29
|
+
} else if (event.key === 'Escape') {
|
|
30
|
+
event.preventDefault()
|
|
31
|
+
emit('close')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function beginEdit() {
|
|
36
|
+
editing.value = true
|
|
37
|
+
nextTick(() => {
|
|
38
|
+
input.value?.focus()
|
|
39
|
+
input.value?.select()
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function focusInput() { beginEdit() }
|
|
44
|
+
defineExpose({ focusInput })
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div
|
|
49
|
+
class="link-popover"
|
|
50
|
+
:style="{ left: `${position.left}px`, top: `${position.top}px` }"
|
|
51
|
+
@mouseenter="emit('keep')"
|
|
52
|
+
@mouseleave="emit('leave')"
|
|
53
|
+
>
|
|
54
|
+
<input ref="input" v-model="draft" class="link-input" :class="{ readonly: !editing }" :readonly="!editing" aria-label="链接地址" @dblclick="beginEdit" @keydown="onKeydown" />
|
|
55
|
+
<button v-if="editing" type="button" class="link-action primary" title="更新链接 (Enter)" aria-label="更新链接" @click="submit">更新</button>
|
|
56
|
+
<button v-else type="button" class="icon-action" title="编辑链接地址" aria-label="编辑链接地址" @click="beginEdit"><AppIcon name="edit" :size="18" /></button>
|
|
57
|
+
<button type="button" class="icon-action" title="移除链接,保留文案" aria-label="移除链接" @click="emit('remove')"><AppIcon name="removeLink" :size="18" /></button>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
.link-popover {
|
|
63
|
+
position: fixed;
|
|
64
|
+
z-index: 125;
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 6px;
|
|
68
|
+
width: min(430px, calc(100vw - 24px));
|
|
69
|
+
padding: 7px;
|
|
70
|
+
border: 1px solid var(--mm-border);
|
|
71
|
+
border-radius: 9px;
|
|
72
|
+
background: var(--mm-panel-bg);
|
|
73
|
+
box-shadow: 0 10px 28px rgb(0 0 0 / .2);
|
|
74
|
+
transform: translateX(-50%);
|
|
75
|
+
}
|
|
76
|
+
.link-input { flex: 1; min-width: 0; height: 32px; padding: 0 9px; border: 1px solid var(--mm-border); border-radius: 6px; outline: 0; background: var(--mm-canvas-bg); color: var(--mm-text); font-size: 13px; }
|
|
77
|
+
.link-input:focus { border-color: var(--mm-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--mm-accent) 20%, transparent); }
|
|
78
|
+
.link-input.readonly { border-color: transparent; color: var(--mm-text-dim); cursor: default; }
|
|
79
|
+
.link-action, .icon-action { height: 32px; border: 0; border-radius: 6px; cursor: pointer; }
|
|
80
|
+
.link-action { padding: 0 11px; background: var(--mm-accent); color: white; }
|
|
81
|
+
.icon-action { display: inline-flex; width: 32px; align-items: center; justify-content: center; background: transparent; color: var(--mm-text-dim); }
|
|
82
|
+
.icon-action:hover { background: var(--mm-hover); color: var(--mm-text); }
|
|
83
|
+
</style>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
import type { MarkdownDiagnostic } from '@tnotesjs/mindmap-core'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(defineProps<{ modelValue: string; diagnostics?: readonly MarkdownDiagnostic[] }>(), {
|
|
6
|
+
diagnostics: () => [],
|
|
7
|
+
})
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
'update:modelValue': [value: string]
|
|
10
|
+
pasteImage: [blob: Blob, selectionStart: number, selectionEnd: number]
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const draft = ref(props.modelValue)
|
|
14
|
+
const textarea = ref<HTMLTextAreaElement>()
|
|
15
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
16
|
+
let typing = false
|
|
17
|
+
|
|
18
|
+
watch(
|
|
19
|
+
() => props.modelValue,
|
|
20
|
+
(md) => {
|
|
21
|
+
// 正在输入时不打断用户,输入停顿后由其它视图变更回流
|
|
22
|
+
if (!typing) draft.value = md
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
function onInput(e: Event) {
|
|
27
|
+
const value = (e.target as HTMLTextAreaElement).value
|
|
28
|
+
draft.value = value
|
|
29
|
+
typing = true
|
|
30
|
+
if (timer) clearTimeout(timer)
|
|
31
|
+
timer = setTimeout(() => {
|
|
32
|
+
typing = false
|
|
33
|
+
emit('update:modelValue', value)
|
|
34
|
+
}, 300)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function flushDraft() {
|
|
38
|
+
if (!typing) return
|
|
39
|
+
if (timer) clearTimeout(timer)
|
|
40
|
+
timer = null
|
|
41
|
+
typing = false
|
|
42
|
+
emit('update:modelValue', draft.value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function goToDiagnostic(item: MarkdownDiagnostic) {
|
|
46
|
+
const input = textarea.value
|
|
47
|
+
if (!input) return
|
|
48
|
+
const lines = draft.value.split(/\r?\n/)
|
|
49
|
+
let offset = 0
|
|
50
|
+
for (let i = 0; i < item.line - 1; i++) offset += (lines[i]?.length ?? 0) + 1
|
|
51
|
+
const start = Math.min(draft.value.length, offset + Math.max(0, item.column - 1))
|
|
52
|
+
input.focus()
|
|
53
|
+
input.setSelectionRange(start, Math.min(draft.value.length, start + 1))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function selectAllFromHost(): void {
|
|
57
|
+
const input = textarea.value
|
|
58
|
+
if (!input) return
|
|
59
|
+
input.focus()
|
|
60
|
+
input.setSelectionRange(0, input.value.length)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
defineExpose({ selectAllFromHost })
|
|
64
|
+
|
|
65
|
+
function onPaste(e: ClipboardEvent) {
|
|
66
|
+
const image = [...(e.clipboardData?.items ?? [])]
|
|
67
|
+
.find((item) => item.kind === 'file' && item.type.startsWith('image/'))
|
|
68
|
+
?.getAsFile()
|
|
69
|
+
if (!image) return
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
const input = e.currentTarget as HTMLTextAreaElement
|
|
72
|
+
draft.value = input.value
|
|
73
|
+
flushDraft()
|
|
74
|
+
emit('pasteImage', image, input.selectionStart, input.selectionEnd)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onBeforeUnmount(() => {
|
|
78
|
+
flushDraft()
|
|
79
|
+
})
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<div class="markdown-view">
|
|
84
|
+
<div v-if="diagnostics.length > 0" class="source-diagnostics" role="alert">
|
|
85
|
+
<div class="diagnostic-title">源码格式不合法;修复前只能停留在源码视图</div>
|
|
86
|
+
<button
|
|
87
|
+
v-for="(item, index) in diagnostics"
|
|
88
|
+
:key="`${item.line}:${item.column}:${item.code}:${index}`"
|
|
89
|
+
class="diagnostic-item"
|
|
90
|
+
type="button"
|
|
91
|
+
@click="goToDiagnostic(item)"
|
|
92
|
+
>
|
|
93
|
+
第 {{ item.line }} 行,第 {{ item.column }} 列:{{ item.message }}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<textarea
|
|
97
|
+
ref="textarea"
|
|
98
|
+
class="md-textarea"
|
|
99
|
+
:value="draft"
|
|
100
|
+
spellcheck="false"
|
|
101
|
+
placeholder="# 根节点 - 子节点 - 孙节点"
|
|
102
|
+
@input="onInput"
|
|
103
|
+
@paste="onPaste"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<style scoped>
|
|
109
|
+
.markdown-view {
|
|
110
|
+
height: 100%;
|
|
111
|
+
background: var(--mm-panel-bg);
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-direction: column;
|
|
115
|
+
}
|
|
116
|
+
.source-diagnostics {
|
|
117
|
+
flex: none;
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
gap: 4px;
|
|
121
|
+
max-height: 34%;
|
|
122
|
+
overflow: auto;
|
|
123
|
+
padding: 10px 16px;
|
|
124
|
+
border-bottom: 1px solid rgb(207 76 76 / 0.28);
|
|
125
|
+
background: rgb(207 76 76 / 0.08);
|
|
126
|
+
}
|
|
127
|
+
.diagnostic-title {
|
|
128
|
+
color: #b33b3b;
|
|
129
|
+
font-size: 13px;
|
|
130
|
+
font-weight: 650;
|
|
131
|
+
}
|
|
132
|
+
.diagnostic-item {
|
|
133
|
+
width: fit-content;
|
|
134
|
+
max-width: 100%;
|
|
135
|
+
padding: 0;
|
|
136
|
+
border: none;
|
|
137
|
+
background: transparent;
|
|
138
|
+
color: var(--mm-text);
|
|
139
|
+
font: inherit;
|
|
140
|
+
font-size: 12px;
|
|
141
|
+
text-align: left;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
.diagnostic-item:hover {
|
|
145
|
+
color: #b33b3b;
|
|
146
|
+
text-decoration: underline;
|
|
147
|
+
}
|
|
148
|
+
.md-textarea {
|
|
149
|
+
flex: 1;
|
|
150
|
+
resize: none;
|
|
151
|
+
border: none;
|
|
152
|
+
outline: none;
|
|
153
|
+
padding: 16px 20px;
|
|
154
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
155
|
+
font-size: 13px;
|
|
156
|
+
line-height: 1.7;
|
|
157
|
+
background: transparent;
|
|
158
|
+
color: var(--mm-text);
|
|
159
|
+
/* Nested under ProseMirror.virtual-cursor-enabled (caret-color: transparent). */
|
|
160
|
+
caret-color: var(--mm-accent, var(--mm-text, #3b82f6));
|
|
161
|
+
min-height: 0;
|
|
162
|
+
}
|
|
163
|
+
</style>
|