@tnotesjs/ui 0.1.1 → 0.1.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.
Files changed (41) hide show
  1. package/README.md +52 -29
  2. package/dist/code/highlight.d.ts +42 -0
  3. package/dist/code/highlight.js +138 -0
  4. package/dist/components/Footprints/parse.d.ts +17 -0
  5. package/dist/components/Footprints/parse.js +105 -0
  6. package/dist/components/Mindmap/markdown.d.ts +20 -0
  7. package/dist/components/Mindmap/markdown.js +60 -0
  8. package/dist/markdown/image.d.ts +33 -0
  9. package/dist/markdown/image.js +83 -0
  10. package/package.json +46 -6
  11. package/src/browser/clipboard.ts +32 -0
  12. package/src/browser/theme.ts +22 -0
  13. package/src/code/highlight.test.ts +55 -0
  14. package/src/code/highlight.ts +171 -0
  15. package/src/components/Badge/Badge.vue +43 -0
  16. package/src/components/CodeBlock/CodeBlock.vue +172 -0
  17. package/src/components/CodeGroup/CodeGroup.vue +96 -0
  18. package/src/components/ImagePreview/ImagePreview.vue +315 -0
  19. package/src/components/Mermaid/Mermaid.test.ts +54 -0
  20. package/src/components/Mermaid/Mermaid.vue +229 -56
  21. package/src/components/Mermaid/adoptLiveSvg.test.ts +16 -0
  22. package/src/components/Mermaid/adoptLiveSvg.ts +15 -0
  23. package/src/components/Mindmap/Mindmap.vue +2 -0
  24. package/src/components/sharedMedia.test.ts +139 -0
  25. package/src/entries/bilibili-video.ts +1 -0
  26. package/src/entries/footprints.ts +1 -0
  27. package/src/entries/mermaid.ts +1 -0
  28. package/src/entries/mindmap.ts +1 -0
  29. package/src/entries/notes-table.ts +2 -0
  30. package/src/entries/word-list.ts +1 -0
  31. package/src/icons/check.svg +3 -0
  32. package/src/icons/copy.svg +4 -0
  33. package/src/index.ts +31 -18
  34. package/src/markdown/image.test.ts +88 -0
  35. package/src/markdown/image.ts +115 -0
  36. package/src/styles/code.css +184 -0
  37. package/src/styles/prose.css +211 -0
  38. package/src/styles/swiper.css +151 -0
  39. package/src/styles/tokens.css +54 -18
  40. package/src/swiper/hydrate.test.ts +65 -0
  41. package/src/swiper/hydrate.ts +164 -0
@@ -0,0 +1,315 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
3
+
4
+ const props = withDefaults(defineProps<{ selector?: string }>(), {
5
+ selector: ".tn-prose img, .vp-doc img",
6
+ });
7
+ const visible = ref(false);
8
+ const images = ref<string[]>([]);
9
+ const index = ref(0);
10
+ const scale = ref(1);
11
+ const x = ref(0);
12
+ const y = ref(0);
13
+ const dragging = ref(false);
14
+ const overlay = ref<HTMLElement>();
15
+ let pointerStart = { x: 0, y: 0, baseX: 0, baseY: 0 };
16
+ let swiperDownX = 0;
17
+ let previousOverflow = "";
18
+ let previousFocus: HTMLElement | null = null;
19
+
20
+ const source = computed(() => images.value[index.value] || "");
21
+ const transform = computed(
22
+ () => `translate(${x.value}px, ${y.value}px) scale(${scale.value})`,
23
+ );
24
+
25
+ function eligible(image: HTMLImageElement): boolean {
26
+ if (
27
+ image.dataset.preview === "false" ||
28
+ image.closest(".tn-preview-ignore, button")
29
+ )
30
+ return false;
31
+ return !(
32
+ image.naturalWidth > 0 &&
33
+ image.naturalWidth < 50 &&
34
+ image.naturalHeight < 50
35
+ );
36
+ }
37
+
38
+ function reset(): void {
39
+ scale.value = 1;
40
+ x.value = 0;
41
+ y.value = 0;
42
+ }
43
+
44
+ function open(image: HTMLImageElement): void {
45
+ const candidates = [
46
+ ...document.querySelectorAll<HTMLImageElement>(props.selector),
47
+ ].filter(eligible);
48
+ images.value = candidates
49
+ .map((item) => item.currentSrc || item.src)
50
+ .filter(Boolean);
51
+ const selected = image.currentSrc || image.src;
52
+ const selectedIndex = images.value.indexOf(selected);
53
+ index.value = selectedIndex < 0 ? 0 : selectedIndex;
54
+ if (selectedIndex < 0 && selected) images.value = [selected];
55
+ if (images.value.length === 0) return;
56
+ reset();
57
+ previousFocus =
58
+ document.activeElement instanceof HTMLElement
59
+ ? document.activeElement
60
+ : null;
61
+ previousOverflow = document.documentElement.style.overflow;
62
+ document.documentElement.style.overflow = "hidden";
63
+ visible.value = true;
64
+ void nextTick(() => overlay.value?.focus({ preventScroll: true }));
65
+ }
66
+
67
+ function close(): void {
68
+ visible.value = false;
69
+ document.documentElement.style.overflow = previousOverflow;
70
+ previousFocus?.focus({ preventScroll: true });
71
+ }
72
+
73
+ function move(step: number): void {
74
+ if (images.value.length < 2) return;
75
+ index.value =
76
+ (index.value + step + images.value.length) % images.value.length;
77
+ reset();
78
+ }
79
+
80
+ function zoom(factor: number): void {
81
+ scale.value = Math.min(6, Math.max(0.2, scale.value * factor));
82
+ }
83
+
84
+ function resolvePreviewImage(target: EventTarget | null): HTMLImageElement | null {
85
+ if (target instanceof HTMLImageElement) return target;
86
+ if (target instanceof Element) {
87
+ return target.closest<HTMLImageElement>("img");
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function onDocumentClick(event: MouseEvent): void {
93
+ if (visible.value) return;
94
+ const image = resolvePreviewImage(event.target);
95
+ if (!image || !image.matches(props.selector) || !eligible(image)) return;
96
+ if (
97
+ image.closest(".swiper-container") &&
98
+ Math.abs(event.clientX - swiperDownX) > 5
99
+ )
100
+ return;
101
+ if (image.closest("a")) {
102
+ event.preventDefault();
103
+ event.stopPropagation();
104
+ }
105
+ open(image);
106
+ }
107
+
108
+ function onPreviewRequest(event: Event): void {
109
+ const detail = (event as CustomEvent<HTMLImageElement | { src?: string }>).detail;
110
+ if (detail instanceof HTMLImageElement) {
111
+ open(detail);
112
+ return;
113
+ }
114
+ const src = detail && typeof detail.src === "string" ? detail.src : "";
115
+ if (!src) return;
116
+ images.value = [src];
117
+ index.value = 0;
118
+ reset();
119
+ previousFocus =
120
+ document.activeElement instanceof HTMLElement
121
+ ? document.activeElement
122
+ : null;
123
+ previousOverflow = document.documentElement.style.overflow;
124
+ document.documentElement.style.overflow = "hidden";
125
+ visible.value = true;
126
+ void nextTick(() => overlay.value?.focus({ preventScroll: true }));
127
+ }
128
+
129
+ function onKeydown(event: KeyboardEvent): void {
130
+ if (!visible.value) return;
131
+ if (event.key === "Escape") close();
132
+ else if (event.key === "ArrowLeft") move(-1);
133
+ else if (event.key === "ArrowRight") move(1);
134
+ else if (event.key === "ArrowUp" || event.key === "+") zoom(1.1);
135
+ else if (event.key === "ArrowDown" || event.key === "-") zoom(1 / 1.1);
136
+ else return;
137
+ event.preventDefault();
138
+ }
139
+
140
+ function onPointerDown(event: PointerEvent): void {
141
+ dragging.value = true;
142
+ pointerStart = {
143
+ x: event.clientX,
144
+ y: event.clientY,
145
+ baseX: x.value,
146
+ baseY: y.value,
147
+ };
148
+ window.addEventListener("pointermove", onPointerMove);
149
+ window.addEventListener("pointerup", onPointerUp, { once: true });
150
+ }
151
+
152
+ function onPointerMove(event: PointerEvent): void {
153
+ if (!dragging.value) return;
154
+ x.value = pointerStart.baseX + event.clientX - pointerStart.x;
155
+ y.value = pointerStart.baseY + event.clientY - pointerStart.y;
156
+ }
157
+
158
+ function onPointerUp(): void {
159
+ dragging.value = false;
160
+ window.removeEventListener("pointermove", onPointerMove);
161
+ }
162
+
163
+ function onGlobalPointerDown(event: PointerEvent): void {
164
+ swiperDownX = event.clientX;
165
+ }
166
+
167
+ onMounted(() => {
168
+ document.addEventListener("click", onDocumentClick, true);
169
+ document.addEventListener("tn:preview-image", onPreviewRequest);
170
+ document.addEventListener("keydown", onKeydown);
171
+ document.addEventListener("pointerdown", onGlobalPointerDown, true);
172
+ });
173
+
174
+ onBeforeUnmount(() => {
175
+ document.removeEventListener("click", onDocumentClick, true);
176
+ document.removeEventListener("tn:preview-image", onPreviewRequest);
177
+ document.removeEventListener("keydown", onKeydown);
178
+ document.removeEventListener("pointerdown", onGlobalPointerDown, true);
179
+ window.removeEventListener("pointermove", onPointerMove);
180
+ window.removeEventListener("pointerup", onPointerUp);
181
+ if (visible.value) document.documentElement.style.overflow = previousOverflow;
182
+ });
183
+ </script>
184
+
185
+ <template>
186
+ <Teleport to="body">
187
+ <div
188
+ v-if="visible"
189
+ ref="overlay"
190
+ class="tn-image-preview"
191
+ role="dialog"
192
+ aria-modal="true"
193
+ aria-label="图片预览"
194
+ tabindex="-1"
195
+ @click.self="close"
196
+ @wheel.prevent="zoom($event.deltaY < 0 ? 1.1 : 1 / 1.1)"
197
+ >
198
+ <button
199
+ v-if="images.length > 1"
200
+ class="tn-image-preview__previous"
201
+ type="button"
202
+ aria-label="上一张"
203
+ @click="move(-1)"
204
+ >
205
+ ‹
206
+ </button>
207
+ <button
208
+ v-if="images.length > 1"
209
+ class="tn-image-preview__next"
210
+ type="button"
211
+ aria-label="下一张"
212
+ @click="move(1)"
213
+ >
214
+ ›
215
+ </button>
216
+ <div class="tn-image-preview__tools">
217
+ <button type="button" aria-label="缩小" @click="zoom(1 / 1.1)">
218
+ −
219
+ </button>
220
+ <button type="button" aria-label="还原" @click="reset">↺</button>
221
+ <button type="button" aria-label="放大" @click="zoom(1.1)">+</button>
222
+ <button type="button" aria-label="关闭" @click="close">×</button>
223
+ </div>
224
+ <img
225
+ :src="source"
226
+ alt=""
227
+ draggable="false"
228
+ :class="{ 'is-dragging': dragging }"
229
+ :style="{ transform }"
230
+ @pointerdown="onPointerDown"
231
+ />
232
+ <span v-if="images.length > 1" class="tn-image-preview__counter"
233
+ >{{ index + 1 }} / {{ images.length }}</span
234
+ >
235
+ </div>
236
+ </Teleport>
237
+ </template>
238
+
239
+ <style scoped>
240
+ .tn-image-preview {
241
+ position: fixed;
242
+ inset: 0;
243
+ z-index: 10000;
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ background: rgb(0 0 0 / 85%);
248
+ backdrop-filter: blur(4px);
249
+ }
250
+ .tn-image-preview > img {
251
+ max-width: 90vw;
252
+ max-height: 90vh;
253
+ user-select: none;
254
+ cursor: grab;
255
+ transition: transform 100ms ease-out;
256
+ }
257
+ .tn-image-preview > img.is-dragging {
258
+ cursor: grabbing;
259
+ }
260
+ .tn-image-preview button {
261
+ display: grid;
262
+ place-items: center;
263
+ border: 1px solid rgb(255 255 255 / 15%);
264
+ border-radius: 8px;
265
+ background: rgb(42 42 42 / 85%);
266
+ color: white;
267
+ cursor: pointer;
268
+ font: 24px/1 var(--tn-font-sans);
269
+ }
270
+ .tn-image-preview button:hover {
271
+ background: rgb(255 255 255 / 18%);
272
+ }
273
+ .tn-image-preview button:focus-visible {
274
+ outline: 2px solid var(--tn-c-brand);
275
+ outline-offset: 2px;
276
+ }
277
+ .tn-image-preview__tools {
278
+ position: absolute;
279
+ z-index: 1;
280
+ top: 16px;
281
+ right: 16px;
282
+ display: flex;
283
+ gap: 8px;
284
+ }
285
+ .tn-image-preview__tools button {
286
+ width: 38px;
287
+ height: 38px;
288
+ }
289
+ .tn-image-preview__previous,
290
+ .tn-image-preview__next {
291
+ position: absolute;
292
+ z-index: 1;
293
+ top: 50%;
294
+ width: 52px;
295
+ height: 52px;
296
+ border-radius: 50% !important;
297
+ transform: translateY(-50%);
298
+ }
299
+ .tn-image-preview__previous {
300
+ left: 24px;
301
+ }
302
+ .tn-image-preview__next {
303
+ right: 24px;
304
+ }
305
+ .tn-image-preview__counter {
306
+ position: absolute;
307
+ bottom: 20px;
308
+ left: 50%;
309
+ border-radius: 999px;
310
+ background: rgb(0 0 0 / 65%);
311
+ padding: 7px 14px;
312
+ color: white;
313
+ transform: translateX(-50%);
314
+ }
315
+ </style>
@@ -0,0 +1,54 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { createApp, defineComponent, h, nextTick, type Component } from 'vue'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import Mermaid from './Mermaid.vue'
7
+
8
+ const cleanups: Array<() => void> = []
9
+
10
+ function mount(component: Component, props: Record<string, unknown> = {}): HTMLElement {
11
+ const host = document.createElement('div')
12
+ document.body.append(host)
13
+ const app = createApp(defineComponent({ render: () => h(component, props) }))
14
+ app.mount(host)
15
+ cleanups.push(() => {
16
+ app.unmount()
17
+ host.remove()
18
+ })
19
+ return host
20
+ }
21
+
22
+ afterEach(() => {
23
+ cleanups
24
+ .splice(0)
25
+ .reverse()
26
+ .forEach((cleanup) => cleanup())
27
+ document.body.innerHTML = ''
28
+ })
29
+
30
+ describe('Mermaid preview host', () => {
31
+ it('keeps the diagram host laid out while loading (note 0020 diagrams)', async () => {
32
+ const sources = [
33
+ `flowchart LR\n Desk --> UI`,
34
+ `sequenceDiagram\n participant Desk\n participant Site\n Desk->>Site: hi`,
35
+ `graph TD\n A[Start] --> B{ok}`
36
+ ]
37
+ for (const source of sources) {
38
+ const host = mount(Mermaid, { source })
39
+ await nextTick()
40
+ const diagram = host.querySelector('.tn-mermaid__diagram')
41
+ expect(diagram).not.toBeNull()
42
+ // Mermaid 11 needs layout during mermaid.render(); do not visibility:hidden the host.
43
+ expect(diagram!.classList.contains('is-obscured')).toBe(false)
44
+ }
45
+ })
46
+
47
+ it('only obscures the host for empty or error states', async () => {
48
+ const emptyHost = mount(Mermaid, { source: '' })
49
+ await nextTick()
50
+ expect(emptyHost.querySelector('.tn-mermaid__diagram')!.classList.contains('is-obscured')).toBe(
51
+ true
52
+ )
53
+ })
54
+ })