@tnotesjs/ui 0.3.2 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tnotesjs/ui",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Shared Vue UI for TNotes built-in blocks — consumed by the TNotes SSG and Desk.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,7 +2,16 @@
2
2
  import { CanvasEditor, CanvasViewer, MindmapSession } from '@tnotesjs/mindmap-core'
3
3
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
4
4
 
5
- import { applyInitialExpandLevel, normalizeExpandLevel } from './expandLevel'
5
+ import {
6
+ MAX_EXPAND_LEVEL,
7
+ MIN_EXPAND_LEVEL,
8
+ acceptsExpandLevelEdit,
9
+ applyInitialExpandLevel,
10
+ normalizeExpandLevel,
11
+ resolvedExpandLevel,
12
+ stepExpandLevel
13
+ } from './expandLevel'
14
+ import { FORCE_EXIT_FULLSCREEN_EVENT, reconcileMindmapFullscreen } from './fullscreenFlag'
6
15
  import { normalizeMindmapMarkdown } from './markdown'
7
16
  import MindmapOutlineNode from './MindmapOutlineNode.vue'
8
17
  import MindmapViewIcon from './MindmapViewIcon.vue'
@@ -20,8 +29,6 @@ type PreviewView = 'mindmap' | 'outline' | 'source'
20
29
 
21
30
  /** One fullscreen mindmap at a time across preview instances on the page. */
22
31
  const FS_GATE_KEY = '__tnotesjs_mindmap_fullscreen_gate__'
23
- const FS_BODY_ATTR = 'tnMindmapFs'
24
- const FORCE_EXIT_FULLSCREEN_EVENT = 'tnotes-mindmap-force-exit-fullscreen'
25
32
 
26
33
  type FsGateState = { owners: Map<symbol, () => void> }
27
34
 
@@ -52,15 +59,7 @@ function claimMindmapFullscreen(id: symbol): void {
52
59
 
53
60
  function syncBodyFullscreenAttr(activeRoot: HTMLElement | null): void {
54
61
  if (typeof document === 'undefined') return
55
- if (activeRoot?.classList.contains('is-fullscreen')) {
56
- document.body.dataset[FS_BODY_ATTR] = '1'
57
- document.documentElement.dataset[FS_BODY_ATTR] = '1'
58
- return
59
- }
60
- if (!document.querySelector('.mindmap-preview.is-fullscreen')) {
61
- delete document.body.dataset[FS_BODY_ATTR]
62
- delete document.documentElement.dataset[FS_BODY_ATTR]
63
- }
62
+ reconcileMindmapFullscreen(activeRoot, document.querySelectorAll<HTMLElement>('.mindmap-preview'))
64
63
  }
65
64
 
66
65
  function forceExitPeerFullscreen(activeRoot: HTMLElement | null): void {
@@ -274,9 +273,127 @@ function setExpandLevel(raw: number): void {
274
273
  emit('expandLevelChange', level)
275
274
  }
276
275
 
276
+ /** The value the field held when it was focused — what ESC / an empty blur restores. */
277
+ let expandLevelBeforeFocus: number | null = null
278
+
279
+ /**
280
+ * Refuse a non-digit before it ever reaches the field. Cancelling `beforeinput`
281
+ * leaves the value and the selection untouched, which is what keeps the field
282
+ * usable after a rejected key — restoring `input.value` after the fact leaves
283
+ * the browser's editing state wedged, and nothing types into the field again.
284
+ *
285
+ * The first character cannot be `0` (the range starts at 1); a second character
286
+ * completes a 1–99 number, so `0` is allowed there (`10`, `20`, …).
287
+ */
288
+ function onExpandLevelBeforeInput(event: InputEvent): void {
289
+ // Only text being inserted is judged; deletions and history carry no `data`.
290
+ if (event.data === null) return
291
+ if (event.inputType !== 'insertText') return
292
+ if (!/^\d+$/.test(event.data)) {
293
+ event.preventDefault()
294
+ return
295
+ }
296
+ const input = event.target as HTMLInputElement
297
+ const replacingAll = input.selectionStart === 0 && input.selectionEnd === input.value.length
298
+ const next = replacingAll
299
+ ? event.data
300
+ : input.value.slice(0, input.selectionStart ?? input.value.length) +
301
+ event.data +
302
+ input.value.slice(input.selectionEnd ?? input.value.length)
303
+ if (!acceptsExpandLevelEdit(next)) event.preventDefault()
304
+ }
305
+
277
306
  function onExpandLevelInput(event: Event): void {
278
- const value = Number((event.target as HTMLInputElement).value)
279
- setExpandLevel(value)
307
+ const input = event.target as HTMLInputElement
308
+ // Safety net for input that skips `beforeinput` (paste, IME, drag-and-drop, a
309
+ // programmatic value): anything that cannot be a 1–99 number is dropped.
310
+ if (!acceptsExpandLevelEdit(input.value)) {
311
+ input.value = String(expandLevel.value)
312
+ input.select()
313
+ return
314
+ }
315
+ if (input.value === '') return
316
+ const typed = Number(input.value)
317
+ // `0` (and anything above the cap) stays in the field uncommitted; blur clamps it.
318
+ if (typed >= MIN_EXPAND_LEVEL && typed <= MAX_EXPAND_LEVEL) setExpandLevel(typed)
319
+ }
320
+
321
+ /**
322
+ * Editing ends: an empty field means "no input", so the pre-focus value comes
323
+ * back; otherwise whatever was typed is clamped into 1–99 and committed.
324
+ */
325
+ function onExpandLevelBlur(event: Event): void {
326
+ const input = event.target as HTMLInputElement
327
+ const restore = expandLevelBeforeFocus
328
+ expandLevelBeforeFocus = null
329
+ const level = resolvedExpandLevel(input.value, restore ?? expandLevel.value)
330
+ input.value = String(level)
331
+ setExpandLevel(level)
332
+ }
333
+
334
+ /** ESC abandons the edit outright, wherever the field happens to be. */
335
+ function onExpandLevelEscape(event: KeyboardEvent): boolean {
336
+ if (event.key !== 'Escape') return false
337
+ const restore = expandLevelBeforeFocus ?? expandLevel.value
338
+ event.preventDefault()
339
+ event.stopPropagation()
340
+ const input = event.target as HTMLInputElement
341
+ input.value = String(restore)
342
+ setExpandLevel(restore)
343
+ input.blur()
344
+ return true
345
+ }
346
+
347
+ /**
348
+ * Backspace clears the field in one press. Editing a single number digit by digit
349
+ * has no use: an empty field here means "no input" — it shows the hint, and blur
350
+ * puts the pre-focus value back.
351
+ */
352
+ function onExpandLevelClearKey(event: KeyboardEvent): boolean {
353
+ if (event.key !== 'Backspace' && event.key !== 'Delete') return false
354
+ const input = event.target as HTMLInputElement
355
+ if (input.value === '') return false
356
+ event.preventDefault()
357
+ input.value = ''
358
+ return true
359
+ }
360
+
361
+ /**
362
+ * All four arrows step the level. The field is a text input (see
363
+ * `onExpandLevelFocus`), so ↑/↓ no longer come free from the number input and
364
+ * have to be handled here alongside ←/→ — either hand stays on the arrows.
365
+ * A caret inside one number has nothing to scroll, which is what leaves the
366
+ * horizontal arrows free to take.
367
+ */
368
+ function onExpandLevelKeydown(event: KeyboardEvent): void {
369
+ if (onExpandLevelEscape(event)) return
370
+ if (onExpandLevelClearKey(event)) return
371
+ const step = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }[event.key]
372
+ if (step === undefined) return
373
+ // Leave modifiers alone: Cmd/Alt+← are word-jump / back shortcuts.
374
+ if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
375
+ const input = event.target as HTMLInputElement
376
+ const current = Number(input.value)
377
+ const base = /^\d{1,2}$/.test(input.value) ? current : expandLevel.value
378
+ // Clamp rather than lean on `setExpandLevel`: a clamped step is still a step,
379
+ // and the value stays inside the documented 1–99.
380
+ const bounded = stepExpandLevel(base, step as -1 | 1)
381
+ event.preventDefault()
382
+ input.value = String(bounded)
383
+ setExpandLevel(bounded)
384
+ }
385
+
386
+ /**
387
+ * Two digits mean "type the level, not append to it": select the current value on
388
+ * focus so a keystroke replaces it. This is also why the field is a `text` input
389
+ * with `inputmode="numeric"` rather than `type=number` — selection APIs are not
390
+ * available on the latter (the digit would be appended, giving `39`).
391
+ * The pre-focus value is remembered for ESC and for an empty blur.
392
+ */
393
+ function onExpandLevelFocus(event: FocusEvent): void {
394
+ const input = event.target as HTMLInputElement
395
+ expandLevelBeforeFocus = expandLevel.value
396
+ input.select()
280
397
  }
281
398
 
282
399
  function setView(view: PreviewView): void {
@@ -378,6 +495,9 @@ function handleFullscreenChange(): void {
378
495
  } else if (fullscreenMode === 'native') {
379
496
  fullscreenMode = null
380
497
  isFullscreen.value = false
498
+ // `syncBodyFullscreenAttr` also drops the stale `is-fullscreen` class, so
499
+ // ESC (which only fires `fullscreenchange`) lands in the same state as the
500
+ // button path and the toolbar comes back.
381
501
  syncBodyFullscreenAttr(null)
382
502
  }
383
503
  if (activeView.value === 'mindmap') void nextTick(() => zoomToFit())
@@ -637,16 +757,24 @@ onBeforeUnmount(() => {
637
757
  </button>
638
758
  </nav>
639
759
  <span class="mindmap-preview-action-divider" aria-hidden="true" />
640
- <label v-if="expandLevelControl" class="mindmap-preview-expand" title="默认展开层级">
641
- <span class="mindmap-preview-expand-label">层</span>
760
+ <label
761
+ v-if="expandLevelControl"
762
+ class="mindmap-preview-expand"
763
+ :title="`默认展开层级(${MIN_EXPAND_LEVEL}~${MAX_EXPAND_LEVEL})`"
764
+ >
642
765
  <input
643
766
  class="mindmap-preview-expand-input"
644
- type="number"
645
- min="1"
646
- max="20"
767
+ type="text"
768
+ inputmode="numeric"
769
+ maxlength="2"
770
+ :placeholder="`${MIN_EXPAND_LEVEL}~${MAX_EXPAND_LEVEL}`"
647
771
  :value="expandLevel"
648
- aria-label="默认展开层级"
649
- @change="onExpandLevelInput"
772
+ :aria-label="`默认展开层级,${MIN_EXPAND_LEVEL} 到 ${MAX_EXPAND_LEVEL}`"
773
+ @focus="onExpandLevelFocus"
774
+ @beforeinput="onExpandLevelBeforeInput"
775
+ @input="onExpandLevelInput"
776
+ @blur="onExpandLevelBlur"
777
+ @keydown="onExpandLevelKeydown"
650
778
  />
651
779
  </label>
652
780
  <button
@@ -865,20 +993,47 @@ onBeforeUnmount(() => {
865
993
  cursor: default;
866
994
  }
867
995
 
868
- .mindmap-preview-expand-label {
869
- opacity: 0.85;
870
- }
871
-
996
+ /*
997
+ * Just the number: no label, no box, no fill — an underline is enough to say
998
+ * "you can type here". The field is as wide as its placeholder (`1~99`) needs,
999
+ * so the hint is never clipped, and the value is centred so the caret and the
1000
+ * number do not jump as it changes.
1001
+ */
872
1002
  .mindmap-preview-expand-input {
873
- width: 2.4rem;
874
- height: 22px;
875
- padding: 0 4px;
876
- border: 1px solid var(--tn-c-divider);
877
- border-radius: 4px;
878
- background: var(--tn-c-bg);
879
- color: var(--tn-c-text);
880
- font-size: 12px;
881
- line-height: 22px;
1003
+ width: 2.9rem;
1004
+ height: 20px;
1005
+ padding: 0;
1006
+ border: 0;
1007
+ border-bottom: 1px solid var(--tn-c-divider);
1008
+ border-radius: 0;
1009
+ background: transparent;
1010
+ color: var(--tn-c-brand);
1011
+ font-size: 13px;
1012
+ font-weight: 600;
1013
+ line-height: 20px;
1014
+ text-align: center;
1015
+ cursor: text;
1016
+ appearance: textfield;
1017
+ -moz-appearance: textfield;
1018
+
1019
+ &::placeholder {
1020
+ color: var(--tn-c-text-3, var(--tn-c-text-2));
1021
+ font-weight: 400;
1022
+ opacity: 0.75;
1023
+ }
1024
+
1025
+ &:focus {
1026
+ border-bottom-color: var(--tn-c-brand);
1027
+ outline: none;
1028
+ }
1029
+
1030
+ /* Chromium keeps a spinner on text-ish numeric fields; we step via keys. */
1031
+ &::-webkit-outer-spin-button,
1032
+ &::-webkit-inner-spin-button {
1033
+ margin: 0;
1034
+ appearance: none;
1035
+ -webkit-appearance: none;
1036
+ }
882
1037
  }
883
1038
 
884
1039
  .mindmap-canvas-host {
@@ -886,7 +1041,13 @@ onBeforeUnmount(() => {
886
1041
  width: 100%;
887
1042
  height: 440px;
888
1043
  overflow: hidden;
889
- background: var(--tn-c-bg);
1044
+ /*
1045
+ * No background of its own: the canvas is cleared to transparent, and the
1046
+ * container above (`.mindmap-preview`) already paints `--tn-c-bg` with the
1047
+ * panel border. Painting it again here only made the canvas read as a second,
1048
+ * inset panel inside the editor.
1049
+ */
1050
+ background: transparent;
890
1051
  touch-action: none;
891
1052
  user-select: none;
892
1053
 
@@ -1105,9 +1266,28 @@ onBeforeUnmount(() => {
1105
1266
 
1106
1267
  .mindmap-outline :deep(.mindmap-outline-toggle),
1107
1268
  .mindmap-outline :deep(.mindmap-outline-leaf) {
1269
+ display: flex;
1108
1270
  flex: 0 0 18px;
1271
+ align-items: center;
1272
+ justify-content: center;
1109
1273
  width: 18px;
1274
+ height: 24px;
1110
1275
  color: var(--tn-c-text-2);
1276
+ /*
1277
+ * The toggle is a real `<button>`, so it arrives with the browser's default
1278
+ * chrome — 2px outset border, button-face background, 1px 6px padding and a
1279
+ * 13.33px font. Desk never saw it because the preview renders inside
1280
+ * `.milkdown`, whose theme reset (`.milkdown button { border/background: none }`)
1281
+ * swallowed it; the SSG site has no such ancestor, so the box showed up and
1282
+ * the chevron read as a filled square next to the plain `•` leaf. Reset it
1283
+ * here so the outline looks the same wherever the component is mounted.
1284
+ */
1285
+ padding: 0;
1286
+ border: 0;
1287
+ background: none;
1288
+ box-shadow: none;
1289
+ font: inherit;
1290
+ line-height: inherit;
1111
1291
  text-align: center;
1112
1292
  }
1113
1293
 
@@ -1,7 +1,46 @@
1
1
  import type { MindmapNode, MindmapSession } from '@tnotesjs/mindmap-core'
2
2
 
3
+ /**
4
+ * The level field holds at most two digits: 1–99. Kept here as the one source of
5
+ * truth for the clamp, the key handler and the field's own width/placeholder.
6
+ */
7
+ export const MIN_EXPAND_LEVEL = 1
8
+ export const MAX_EXPAND_LEVEL = 99
9
+
3
10
  export function normalizeExpandLevel(value: number): number {
4
- return Math.max(1, Math.trunc(Number(value) || 1))
11
+ const level = Math.trunc(Number(value) || MIN_EXPAND_LEVEL)
12
+ return Math.min(MAX_EXPAND_LEVEL, Math.max(MIN_EXPAND_LEVEL, level))
13
+ }
14
+
15
+ /**
16
+ * What the level field accepts as its complete text: one or two digits, no
17
+ * leading zero (`0` is below the range, `07` is not how a level is written).
18
+ * Empty is allowed while editing — it means "no input yet".
19
+ */
20
+ export function isValidExpandLevelText(text: string): boolean {
21
+ return text === '' || /^[1-9]\d?$/.test(text)
22
+ }
23
+
24
+ /**
25
+ * Whether an edit would leave the field in an acceptable state. `beforeinput`
26
+ * asks this before the browser applies the change, so a rejected key leaves the
27
+ * value *and* the selection untouched — restoring the value afterwards wedges the
28
+ * browser's editing state and the field stops accepting input.
29
+ *
30
+ * `next` is what the field would read after the edit.
31
+ */
32
+ export function acceptsExpandLevelEdit(next: string): boolean {
33
+ return isValidExpandLevelText(next)
34
+ }
35
+
36
+ /** One arrow-key step, clamped to the range. */
37
+ export function stepExpandLevel(value: number, direction: -1 | 1): number {
38
+ return Math.min(MAX_EXPAND_LEVEL, Math.max(MIN_EXPAND_LEVEL, value + direction))
39
+ }
40
+
41
+ /** Editing ended: an empty field drops the current value back. */
42
+ export function resolvedExpandLevel(text: string, previous: number): number {
43
+ return text === '' ? previous : normalizeExpandLevel(Number(text))
5
44
  }
6
45
 
7
46
  function childLevel(node: MindmapNode, root: MindmapNode): number {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The document-wide "a mindmap owns fullscreen" flag.
3
+ *
4
+ * The flag drives an **unscoped**, `!important` rule (in `Mindmap.vue`) that
5
+ * hides the child buttons (`.mindmap-preview-actions`) of every *non*-fullscreen
6
+ * mindmap. So the flag and the `is-fullscreen` classes have to be reconciled
7
+ * together — a stale flag, or a stale class left behind by a path that only
8
+ * observed `fullscreenchange` (ESC) or unmounted a preview, hides every toolbar
9
+ * with no way back short of a page reload.
10
+ *
11
+ * Kept as plain DOM code in its own module so the invariant is unit-testable
12
+ * without mounting the canvas-backed component.
13
+ */
14
+
15
+ export const FS_BODY_ATTR = 'tnMindmapFs'
16
+
17
+ /** Custom event peers dispatch to make each other leave fullscreen. */
18
+ export const FORCE_EXIT_FULLSCREEN_EVENT = 'tnotes-mindmap-force-exit-fullscreen'
19
+
20
+ /**
21
+ * Reconcile the document flag with the fullscreen classes.
22
+ *
23
+ * - `activeRoot` still marked fullscreen → keep the flag (a peer's stray
24
+ * `.is-fullscreen` must not be mistaken for the owner);
25
+ * - otherwise → drop the class from every tracked preview and delete the flag.
26
+ */
27
+ export function reconcileMindmapFullscreen(
28
+ activeRoot: HTMLElement | null,
29
+ previews: ArrayLike<HTMLElement>
30
+ ): void {
31
+ if (typeof document === 'undefined') return
32
+ if (activeRoot?.classList.contains('is-fullscreen')) {
33
+ document.body.dataset[FS_BODY_ATTR] = '1'
34
+ document.documentElement.dataset[FS_BODY_ATTR] = '1'
35
+ return
36
+ }
37
+ for (const el of Array.from(previews)) el.classList.remove('is-fullscreen')
38
+ delete document.body.dataset[FS_BODY_ATTR]
39
+ delete document.documentElement.dataset[FS_BODY_ATTR]
40
+ }