@tnotesjs/ui 0.2.1 → 0.3.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.
@@ -5,6 +5,12 @@ import { isApplePlatform, primaryShortcut } from './platform'
5
5
  const props = withDefaults(
6
6
  defineProps<{
7
7
  position: { left: number; top: number }
8
+ /**
9
+ * 浮层挂载点。默认 `undefined` = 就地渲染,供嵌在笔记里的宿主(Desk / SSG)
10
+ * 使用——菜单是 `position: fixed`,teleport 到 body 会脱离笔记的层叠上下文。
11
+ * 自带整页外壳的宿主(Web / VS Code webview)传 `'body'`。
12
+ */
13
+ teleportTo?: string
8
14
  multiple?: boolean
9
15
  canInsertSibling?: boolean
10
16
  canInsertParent?: boolean
@@ -16,6 +22,7 @@ const props = withDefaults(
16
22
  canFocus?: boolean
17
23
  }>(),
18
24
  {
25
+ teleportTo: undefined,
19
26
  multiple: false,
20
27
  canInsertSibling: true,
21
28
  canInsertParent: true,
@@ -96,85 +103,87 @@ onBeforeUnmount(() => {
96
103
  </script>
97
104
 
98
105
  <template>
99
- <div
100
- ref="menu"
101
- class="canvas-context-menu"
102
- :style="style"
103
- role="menu"
104
- :aria-label="multiple ? '多主题右键菜单' : '主题右键菜单'"
105
- @contextmenu.prevent
106
- >
107
- <template v-if="!multiple">
106
+ <Teleport :to="teleportTo" :disabled="!teleportTo">
107
+ <div
108
+ ref="menu"
109
+ class="canvas-context-menu"
110
+ :style="style"
111
+ role="menu"
112
+ :aria-label="multiple ? '多主题右键菜单' : '主题右键菜单'"
113
+ @contextmenu.prevent
114
+ >
115
+ <template v-if="!multiple">
116
+ <button
117
+ type="button"
118
+ role="menuitem"
119
+ :disabled="!canInsertSibling"
120
+ @click="act('insertSibling')"
121
+ >
122
+ <span>插入同级主题</span><kbd>Enter</kbd>
123
+ </button>
124
+ <button type="button" role="menuitem" @click="act('insertChild')">
125
+ <span>插入下级主题</span><kbd>Tab</kbd>
126
+ </button>
127
+ <button
128
+ type="button"
129
+ role="menuitem"
130
+ :disabled="!canInsertParent"
131
+ @click="act('insertParent')"
132
+ >
133
+ <span>插入上级主题</span><kbd>{{ shiftTab }}</kbd>
134
+ </button>
135
+ <span class="context-divider" />
136
+ </template>
137
+
138
+ <button type="button" role="menuitem" @click="act('copy')">
139
+ <span>复制</span><kbd>{{ primaryShortcut('C') }}</kbd>
140
+ </button>
141
+ <button type="button" role="menuitem" :disabled="!canCut" @click="act('cut')">
142
+ <span>剪切</span><kbd>{{ primaryShortcut('X') }}</kbd>
143
+ </button>
144
+
145
+ <template v-if="!multiple">
146
+ <button type="button" role="menuitem" @click="act('paste')">
147
+ <span>粘贴</span><kbd>{{ primaryShortcut('V') }}</kbd>
148
+ </button>
149
+ <button type="button" role="menuitem" :disabled="!canDuplicate" @click="act('duplicate')">
150
+ <span>创建副本</span><kbd>{{ primaryShortcut('D') }}</kbd>
151
+ </button>
152
+ <button type="button" role="menuitem" :disabled="!canDeleteOnly" @click="act('deleteOnly')">
153
+ <span>仅删除当前主题</span>
154
+ </button>
155
+ </template>
156
+
108
157
  <button
109
158
  type="button"
110
159
  role="menuitem"
111
- :disabled="!canInsertSibling"
112
- @click="act('insertSibling')"
160
+ class="danger"
161
+ :disabled="!canDeleteTree"
162
+ @click="act('deleteTree')"
113
163
  >
114
- <span>插入同级主题</span><kbd>Enter</kbd>
115
- </button>
116
- <button type="button" role="menuitem" @click="act('insertChild')">
117
- <span>插入下级主题</span><kbd>Tab</kbd>
164
+ <span>{{ multiple ? '删除所选主题及下级主题' : '删除当前主题及下级主题' }}</span
165
+ ><kbd>Delete</kbd>
118
166
  </button>
167
+ <span class="context-divider" />
119
168
  <button
120
169
  type="button"
121
170
  role="menuitem"
122
- :disabled="!canInsertParent"
123
- @click="act('insertParent')"
171
+ :disabled="!canToggleSiblings"
172
+ @click="act('toggleSiblings')"
124
173
  >
125
- <span>插入上级主题</span><kbd>{{ shiftTab }}</kbd>
174
+ <span>展开/折叠同级主题</span><kbd>{{ primaryShortcut('.', { shift: true }) }}</kbd>
126
175
  </button>
127
- <span class="context-divider" />
128
- </template>
129
-
130
- <button type="button" role="menuitem" @click="act('copy')">
131
- <span>复制</span><kbd>{{ primaryShortcut('C') }}</kbd>
132
- </button>
133
- <button type="button" role="menuitem" :disabled="!canCut" @click="act('cut')">
134
- <span>剪切</span><kbd>{{ primaryShortcut('X') }}</kbd>
135
- </button>
136
-
137
- <template v-if="!multiple">
138
- <button type="button" role="menuitem" @click="act('paste')">
139
- <span>粘贴</span><kbd>{{ primaryShortcut('V') }}</kbd>
140
- </button>
141
- <button type="button" role="menuitem" :disabled="!canDuplicate" @click="act('duplicate')">
142
- <span>创建副本</span><kbd>{{ primaryShortcut('D') }}</kbd>
143
- </button>
144
- <button type="button" role="menuitem" :disabled="!canDeleteOnly" @click="act('deleteOnly')">
145
- <span>仅删除当前主题</span>
176
+ <button
177
+ v-if="!multiple"
178
+ type="button"
179
+ role="menuitem"
180
+ :disabled="!canFocus"
181
+ @click="act('focus')"
182
+ >
183
+ <span>进入此主题</span><kbd>{{ primaryShortcut(']') }}</kbd>
146
184
  </button>
147
- </template>
148
-
149
- <button
150
- type="button"
151
- role="menuitem"
152
- class="danger"
153
- :disabled="!canDeleteTree"
154
- @click="act('deleteTree')"
155
- >
156
- <span>{{ multiple ? '删除所选主题及下级主题' : '删除当前主题及下级主题' }}</span
157
- ><kbd>Delete</kbd>
158
- </button>
159
- <span class="context-divider" />
160
- <button
161
- type="button"
162
- role="menuitem"
163
- :disabled="!canToggleSiblings"
164
- @click="act('toggleSiblings')"
165
- >
166
- <span>展开/折叠同级主题</span><kbd>{{ primaryShortcut('.', { shift: true }) }}</kbd>
167
- </button>
168
- <button
169
- v-if="!multiple"
170
- type="button"
171
- role="menuitem"
172
- :disabled="!canFocus"
173
- @click="act('focus')"
174
- >
175
- <span>进入此主题</span><kbd>{{ primaryShortcut(']') }}</kbd>
176
- </button>
177
- </div>
185
+ </div>
186
+ </Teleport>
178
187
  </template>
179
188
 
180
189
  <style scoped>
@@ -0,0 +1,113 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
3
+ import IconButton from './IconButton.vue'
4
+ import { isApplePlatform } from './platform'
5
+
6
+ const emit = defineEmits<{
7
+ all: []
8
+ level: [level: 1 | 2 | 3]
9
+ }>()
10
+
11
+ const root = ref<HTMLElement>()
12
+ const open = ref(false)
13
+ const modifier = computed(() => (isApplePlatform() ? '⌘⌥' : 'Ctrl+Alt+'))
14
+ const allShortcut = computed(() => (isApplePlatform() ? '⌘⌥⇧.' : 'Ctrl+Alt+Shift+.'))
15
+
16
+ function chooseAll() {
17
+ emit('all')
18
+ open.value = false
19
+ }
20
+
21
+ function chooseLevel(level: 1 | 2 | 3) {
22
+ emit('level', level)
23
+ open.value = false
24
+ }
25
+
26
+ function onDocumentPointerDown(event: PointerEvent) {
27
+ if (open.value && !root.value?.contains(event.target as Node)) open.value = false
28
+ }
29
+
30
+ function onDocumentKeydown(event: KeyboardEvent) {
31
+ if (event.key === 'Escape') open.value = false
32
+ }
33
+
34
+ onMounted(() => {
35
+ document.addEventListener('pointerdown', onDocumentPointerDown)
36
+ document.addEventListener('keydown', onDocumentKeydown)
37
+ })
38
+
39
+ onBeforeUnmount(() => {
40
+ document.removeEventListener('pointerdown', onDocumentPointerDown)
41
+ document.removeEventListener('keydown', onDocumentKeydown)
42
+ })
43
+ </script>
44
+
45
+ <template>
46
+ <div ref="root" class="collapse-menu">
47
+ <IconButton icon="collapse" label="展开/折叠主题" :active="open" @click="open = !open" />
48
+ <div v-if="open" class="collapse-menu-popover" role="menu" aria-label="展开/折叠主题">
49
+ <div class="collapse-menu-title">展开/折叠主题</div>
50
+ <button type="button" role="menuitem" @click="chooseAll">
51
+ <span>全部主题</span><kbd>{{ allShortcut }}</kbd>
52
+ </button>
53
+ <button
54
+ v-for="level in [1, 2, 3] as const"
55
+ :key="level"
56
+ type="button"
57
+ role="menuitem"
58
+ @click="chooseLevel(level)"
59
+ >
60
+ <span>{{ level }} 级主题</span><kbd>{{ modifier }}{{ level }}</kbd>
61
+ </button>
62
+ </div>
63
+ </div>
64
+ </template>
65
+
66
+ <style scoped>
67
+ .collapse-menu {
68
+ position: relative;
69
+ }
70
+ .collapse-menu-popover {
71
+ position: absolute;
72
+ z-index: 85;
73
+ top: calc(100% + 8px);
74
+ right: 0;
75
+ width: 260px;
76
+ padding: 8px;
77
+ border: 1px solid var(--mm-border);
78
+ border-radius: 12px;
79
+ background: var(--mm-panel-bg);
80
+ color: var(--mm-text);
81
+ box-shadow: 0 14px 38px rgb(0 0 0 / 0.24);
82
+ }
83
+ .collapse-menu-title {
84
+ padding: 7px 11px 9px;
85
+ color: var(--mm-text-dim);
86
+ font-size: 12px;
87
+ }
88
+ .collapse-menu-popover button {
89
+ display: flex;
90
+ width: 100%;
91
+ min-height: 42px;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ padding: 0 11px;
95
+ border: 0;
96
+ border-radius: 7px;
97
+ background: transparent;
98
+ color: inherit;
99
+ font: inherit;
100
+ text-align: left;
101
+ cursor: pointer;
102
+ }
103
+ .collapse-menu-popover button:hover,
104
+ .collapse-menu-popover button:focus-visible {
105
+ background: var(--mm-hover);
106
+ outline: none;
107
+ }
108
+ .collapse-menu-popover kbd {
109
+ color: var(--mm-text-dim);
110
+ font: inherit;
111
+ font-size: 12px;
112
+ }
113
+ </style>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import AppIcon from './AppIcon.vue'
3
+
4
+ withDefaults(
5
+ defineProps<{
6
+ icon: string
7
+ label: string
8
+ size?: number
9
+ active?: boolean
10
+ danger?: boolean
11
+ disabled?: boolean
12
+ }>(),
13
+ { size: 18, active: false, danger: false, disabled: false }
14
+ )
15
+
16
+ defineEmits<{ click: [event: MouseEvent] }>()
17
+ </script>
18
+
19
+ <template>
20
+ <button
21
+ type="button"
22
+ class="icon-button"
23
+ :class="{ active, danger }"
24
+ :title="label"
25
+ :aria-label="label"
26
+ :disabled="disabled"
27
+ @click="$emit('click', $event)"
28
+ >
29
+ <AppIcon :name="icon" :size="size" />
30
+ </button>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .icon-button {
35
+ display: inline-flex;
36
+ width: 32px;
37
+ height: 32px;
38
+ align-items: center;
39
+ justify-content: center;
40
+ padding: 0;
41
+ border: 0;
42
+ border-radius: 7px;
43
+ background: transparent;
44
+ color: var(--mm-text-dim);
45
+ cursor: pointer;
46
+ }
47
+ .icon-button:hover:not(:disabled),
48
+ .icon-button.active {
49
+ background: var(--mm-hover);
50
+ color: var(--mm-text);
51
+ }
52
+ .icon-button.danger {
53
+ color: #d84d57;
54
+ }
55
+ .icon-button:focus-visible {
56
+ outline: 2px solid var(--mm-accent);
57
+ outline-offset: 1px;
58
+ }
59
+ .icon-button:disabled {
60
+ cursor: default;
61
+ opacity: 0.38;
62
+ }
63
+ </style>
@@ -7,8 +7,10 @@ const props = withDefaults(
7
7
  url: string
8
8
  position: { left: number; top: number }
9
9
  startEditing?: boolean
10
+ /** 见 `CanvasContextMenu`:默认就地渲染,整页宿主传 `'body'`。 */
11
+ teleportTo?: string
10
12
  }>(),
11
- { startEditing: false }
13
+ { startEditing: false, teleportTo: undefined }
12
14
  )
13
15
  const emit = defineEmits<{ save: [url: string]; remove: []; keep: []; leave: []; close: [] }>()
14
16
  const draft = ref(props.url)
@@ -50,52 +52,54 @@ defineExpose({ focusInput })
50
52
  </script>
51
53
 
52
54
  <template>
53
- <div
54
- class="link-popover"
55
- :style="{ left: `${position.left}px`, top: `${position.top}px` }"
56
- @mouseenter="emit('keep')"
57
- @mouseleave="emit('leave')"
58
- >
59
- <input
60
- ref="input"
61
- v-model="draft"
62
- class="link-input"
63
- :class="{ readonly: !editing }"
64
- :readonly="!editing"
65
- aria-label="链接地址"
66
- @dblclick="beginEdit"
67
- @keydown="onKeydown"
68
- />
69
- <button
70
- v-if="editing"
71
- type="button"
72
- class="link-action primary"
73
- title="更新链接 (Enter)"
74
- aria-label="更新链接"
75
- @click="submit"
55
+ <Teleport :to="teleportTo" :disabled="!teleportTo">
56
+ <div
57
+ class="link-popover"
58
+ :style="{ left: `${position.left}px`, top: `${position.top}px` }"
59
+ @mouseenter="emit('keep')"
60
+ @mouseleave="emit('leave')"
76
61
  >
77
- 更新
78
- </button>
79
- <button
80
- v-else
81
- type="button"
82
- class="icon-action"
83
- title="编辑链接地址"
84
- aria-label="编辑链接地址"
85
- @click="beginEdit"
86
- >
87
- <AppIcon name="edit" :size="18" />
88
- </button>
89
- <button
90
- type="button"
91
- class="icon-action"
92
- title="移除链接,保留文案"
93
- aria-label="移除链接"
94
- @click="emit('remove')"
95
- >
96
- <AppIcon name="removeLink" :size="18" />
97
- </button>
98
- </div>
62
+ <input
63
+ ref="input"
64
+ v-model="draft"
65
+ class="link-input"
66
+ :class="{ readonly: !editing }"
67
+ :readonly="!editing"
68
+ aria-label="链接地址"
69
+ @dblclick="beginEdit"
70
+ @keydown="onKeydown"
71
+ />
72
+ <button
73
+ v-if="editing"
74
+ type="button"
75
+ class="link-action primary"
76
+ title="更新链接 (Enter)"
77
+ aria-label="更新链接"
78
+ @click="submit"
79
+ >
80
+ 更新
81
+ </button>
82
+ <button
83
+ v-else
84
+ type="button"
85
+ class="icon-action"
86
+ title="编辑链接地址"
87
+ aria-label="编辑链接地址"
88
+ @click="beginEdit"
89
+ >
90
+ <AppIcon name="edit" :size="18" />
91
+ </button>
92
+ <button
93
+ type="button"
94
+ class="icon-action"
95
+ title="移除链接,保留文案"
96
+ aria-label="移除链接"
97
+ @click="emit('remove')"
98
+ >
99
+ <AppIcon name="removeLink" :size="18" />
100
+ </button>
101
+ </div>
102
+ </Teleport>
99
103
  </template>
100
104
 
101
105
  <style scoped>
@@ -37,12 +37,14 @@ function onInput(e: Event) {
37
37
  }, 300)
38
38
  }
39
39
 
40
- function flushDraft() {
41
- if (!typing) return
40
+ /** 有未提交的防抖输入时立即回流,返回被 flush 的草稿;没有则返回 `null`。 */
41
+ function flushDraft(): string | null {
42
+ if (!typing) return null
42
43
  if (timer) clearTimeout(timer)
43
44
  timer = null
44
45
  typing = false
45
46
  emit('update:modelValue', draft.value)
47
+ return draft.value
46
48
  }
47
49
 
48
50
  function goToDiagnostic(item: MarkdownDiagnostic) {
@@ -63,7 +65,7 @@ function selectAllFromHost(): void {
63
65
  input.setSelectionRange(0, input.value.length)
64
66
  }
65
67
 
66
- defineExpose({ selectAllFromHost })
68
+ defineExpose({ selectAllFromHost, flushDraft })
67
69
 
68
70
  function onPaste(e: ClipboardEvent) {
69
71
  const image = [...(e.clipboardData?.items ?? [])]
@@ -13,7 +13,15 @@ import LinkPopover from './LinkPopover.vue'
13
13
  import SelectionToolbar from './SelectionToolbar.vue'
14
14
  import { pasteCanvasOutline, readMindmapClipboard, writeMindmapClipboard } from './mindmapClipboard'
15
15
 
16
- const props = defineProps<{ session: MindmapSession; resolveImageSrc?: (src: string) => string }>()
16
+ const props = defineProps<{
17
+ session: MindmapSession
18
+ resolveImageSrc?: (src: string) => string
19
+ /**
20
+ * 浮层挂载点,透传给 `CanvasContextMenu` / `LinkPopover`。
21
+ * 默认 `undefined` = 就地渲染(嵌在笔记里的 Desk / SSG);整页宿主传 `'body'`。
22
+ */
23
+ teleportTo?: string
24
+ }>()
17
25
 
18
26
  const emit = defineEmits<{
19
27
  ready: [editor: CanvasEditor]
@@ -209,6 +217,7 @@ onBeforeUnmount(() => {
209
217
  v-if="linkEditor"
210
218
  :url="linkEditor.url"
211
219
  :position="linkEditor.position"
220
+ :teleport-to="teleportTo"
212
221
  @save="saveLink"
213
222
  @remove="removeLink"
214
223
  @keep="keepLinkPopover"
@@ -227,6 +236,7 @@ onBeforeUnmount(() => {
227
236
  :can-delete-tree="contextMenu.canDeleteTree"
228
237
  :can-toggle-siblings="contextMenu.canToggleSiblings"
229
238
  :can-focus="contextMenu.canFocus"
239
+ :teleport-to="teleportTo"
230
240
  @insert-sibling="insertSibling"
231
241
  @insert-child="insertChild"
232
242
  @insert-parent="insertParent"