@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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
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
|
|
641
|
-
|
|
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="
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
869
|
-
|
|
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.
|
|
874
|
-
height:
|
|
875
|
-
padding: 0
|
|
876
|
-
border:
|
|
877
|
-
border-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|