@dumiflex/wp-kit 0.6.0 → 0.7.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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/BundlePreview.vue +254 -0
- package/src/CombinePreview.vue +150 -0
- package/src/ConstraintMatrixPreview.vue +6 -22
- package/src/ConstraintPreview.vue +259 -0
- package/src/DerivationCondition.vue +68 -0
- package/src/DerivationPreview.vue +345 -0
- package/src/FixedValuesPreview.vue +184 -0
- package/src/IconPicker.vue +31 -102
- package/src/IconPickerPanel.vue +132 -0
- package/src/LoadingMark.vue +6 -61
- package/src/PayloadPreview.vue +154 -0
- package/src/PostDetailSkeleton.vue +65 -0
- package/src/PostGridSkeleton.vue +61 -0
- package/src/PreviewGroup.vue +75 -0
- package/src/ProfileSkeleton.vue +73 -0
- package/src/RichText.vue +61 -0
- package/src/SelectionBar.vue +90 -0
- package/src/SettingGroup.vue +57 -0
- package/src/SettingRow.vue +127 -0
- package/src/WildcardPreview.vue +607 -0
- package/src/WorkingLine.vue +38 -124
- package/src/canvas-motion.ts +251 -0
- package/src/kind-colour.ts +24 -1
- package/src/linkify.ts +142 -0
- package/src/loading-mark-motion.ts +12 -155
- package/src/matrix-axis.ts +28 -0
- package/src/pi-icons.ts +51 -0
- package/src/preview-readers.ts +401 -0
- package/src/preview.css +194 -0
- package/src/tokens.ts +8 -2
- package/src/working-line-motion.ts +135 -0
package/src/WorkingLine.vue
CHANGED
|
@@ -20,14 +20,16 @@
|
|
|
20
20
|
* never narrower than the word: it widens at once and narrows smoothly once
|
|
21
21
|
* the old word has mostly faded.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
23
|
+
* The marks move on a canvas over the still SVG, so they stay sharp and
|
|
24
|
+
* glide in every browser (`canvas-motion.ts` says why); the SVG is the first
|
|
25
|
+
* paint and stays under reduced motion, where it only breathes.
|
|
26
26
|
*/
|
|
27
27
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
28
28
|
import { BRAND_COLOURS, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
|
|
29
|
+
import { canvasBox, useCanvasMotion } from "./canvas-motion";
|
|
30
|
+
import { TEMPO_MS, TYPING_X, drawWorking, orbitNodes, type WorkingVariant } from "./working-line-motion";
|
|
29
31
|
|
|
30
|
-
export type WorkingVariant
|
|
32
|
+
export type { WorkingVariant } from "./working-line-motion";
|
|
31
33
|
|
|
32
34
|
const props = withDefaults(defineProps<{
|
|
33
35
|
/** What is shown, without the "…" (the line adds it). */
|
|
@@ -55,14 +57,14 @@ const BIG = computed(() => colours.value[0]);
|
|
|
55
57
|
const A = computed(() => colours.value[1]);
|
|
56
58
|
const B = computed(() => colours.value[2]);
|
|
57
59
|
/** The logo's four nodes for the orbit, with steps between the colours. */
|
|
58
|
-
const nodes = computed(() =>
|
|
59
|
-
[911, 512, B.value],
|
|
60
|
-
[512, 911, `color-mix(in srgb, ${B.value} 70%, ${A.value})`],
|
|
61
|
-
[113, 512, `color-mix(in srgb, ${BIG.value} 60%, ${A.value})`],
|
|
62
|
-
[512, 113, A.value],
|
|
63
|
-
] as const);
|
|
60
|
+
const nodes = computed(() => orbitNodes(colours.value));
|
|
64
61
|
/** Typing stars: the big sparkle drawn small three times in a row. */
|
|
65
|
-
const typing = computed(() => [
|
|
62
|
+
const typing = computed(() => TYPING_X.map((x, i) => [x, [A.value, BIG.value, B.value][i] ?? BIG.value] as const));
|
|
63
|
+
|
|
64
|
+
const canvas = ref<HTMLCanvasElement | null>(null);
|
|
65
|
+
const canvasStyle = computed(() => canvasBox(props.size));
|
|
66
|
+
const { live, drawn } = useCanvasMotion(canvas, (ctx, width, ms) =>
|
|
67
|
+
drawWorking(ctx, width, ms, props.variant, TEMPO_MS[props.tempo], colours.value));
|
|
66
68
|
|
|
67
69
|
const measure = ref<HTMLElement | null>(null);
|
|
68
70
|
const boxWidth = ref<number | null>(null);
|
|
@@ -98,41 +100,39 @@ onBeforeUnmount(() => resized?.disconnect());
|
|
|
98
100
|
:data-tempo="tempo"
|
|
99
101
|
:data-variant="variant"
|
|
100
102
|
:data-inline="inline ? 'true' : undefined"
|
|
103
|
+
:data-drawn="drawn ? 'true' : undefined"
|
|
101
104
|
role="status"
|
|
102
105
|
:aria-label="name"
|
|
103
106
|
:title="name"
|
|
104
107
|
:style="{ '--wp-wl-big': BIG, '--wp-wl-a': A, '--wp-wl-b': B }"
|
|
105
108
|
>
|
|
109
|
+
<span class="wp-wl__art" :style="{ width: `${size}px`, height: `${size}px` }">
|
|
106
110
|
<svg class="wp-wl__logo" viewBox="0 0 1024 1024" :width="size" :height="size" aria-hidden="true">
|
|
107
111
|
<template v-if="variant === 'typing'">
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
<path
|
|
113
|
+
v-for="([x, c], i) in typing" :key="i" :style="{ fill: c }"
|
|
114
|
+
:transform="`translate(${x - 256} 256) scale(.5)`" :d="STAR_BIG"
|
|
115
|
+
/>
|
|
111
116
|
</template>
|
|
112
117
|
<g v-else-if="variant === 'shuffle'" transform="translate(512 512) scale(1.45) translate(-512 -512)">
|
|
113
|
-
<path
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
<path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
|
|
117
|
-
</g>
|
|
118
|
+
<path :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
119
|
+
<path :style="{ fill: A }" :d="STAR_A" />
|
|
120
|
+
<path :style="{ fill: B }" :d="STAR_B" />
|
|
118
121
|
</g>
|
|
119
122
|
<template v-else-if="variant === 'orbit'">
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
/>
|
|
125
|
-
</g>
|
|
126
|
-
<path class="wp-wl__star wp-wl__star--big wp-wl__breathe" :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
127
|
-
<path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
|
|
128
|
-
<path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
|
|
123
|
+
<circle v-for="([x, y, c], i) in nodes" :key="i" :style="{ fill: c }" :cx="x" :cy="y" r="70" />
|
|
124
|
+
<path :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
125
|
+
<path :style="{ fill: A }" :d="STAR_A" />
|
|
126
|
+
<path :style="{ fill: B }" :d="STAR_B" />
|
|
129
127
|
</template>
|
|
130
128
|
<g v-else transform="translate(512 512) scale(1.3) translate(-512 -512)">
|
|
131
|
-
<path
|
|
132
|
-
<path
|
|
133
|
-
<path
|
|
129
|
+
<path :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
130
|
+
<path :style="{ fill: A }" :d="STAR_A" />
|
|
131
|
+
<path :style="{ fill: B }" :d="STAR_B" />
|
|
134
132
|
</g>
|
|
135
133
|
</svg>
|
|
134
|
+
<canvas v-if="live" ref="canvas" class="wp-wl__canvas" :style="canvasStyle" aria-hidden="true" />
|
|
135
|
+
</span>
|
|
136
136
|
<span
|
|
137
137
|
class="wp-wl__words"
|
|
138
138
|
:data-grow="growing ? 'true' : undefined"
|
|
@@ -149,7 +149,6 @@ onBeforeUnmount(() => resized?.disconnect());
|
|
|
149
149
|
|
|
150
150
|
<style scoped>
|
|
151
151
|
.wp-wl {
|
|
152
|
-
--wp-wl-t: 2.8s;
|
|
153
152
|
display: flex;
|
|
154
153
|
align-items: center;
|
|
155
154
|
gap: 8px;
|
|
@@ -158,9 +157,7 @@ onBeforeUnmount(() => resized?.disconnect());
|
|
|
158
157
|
font-size: 13px;
|
|
159
158
|
white-space: nowrap;
|
|
160
159
|
}
|
|
161
|
-
.wp-
|
|
162
|
-
.wp-wl[data-tempo="fast"] { --wp-wl-t: 1.5s; }
|
|
163
|
-
.wp-wl__logo { flex-shrink: 0; overflow: visible; }
|
|
160
|
+
.wp-wl__logo { overflow: visible; }
|
|
164
161
|
.wp-wl__words {
|
|
165
162
|
position: relative;
|
|
166
163
|
display: inline-flex;
|
|
@@ -220,100 +217,17 @@ onBeforeUnmount(() => resized?.disconnect());
|
|
|
220
217
|
.wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wb: 0px; }
|
|
221
218
|
}
|
|
222
219
|
|
|
223
|
-
/*
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
* vector paths in place, as sharp as at rest. */
|
|
230
|
-
@property --wp-wl-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
|
|
231
|
-
@property --wp-wl-f { syntax: "<number>"; inherits: false; initial-value: 0; }
|
|
232
|
-
@property --wp-wl-r { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
|
|
233
|
-
@property --wp-wl-s { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
220
|
+
/* The marks move on the canvas; the SVG is the still mark under it. */
|
|
221
|
+
.wp-wl__art { position: relative; display: inline-block; flex-shrink: 0; line-height: 0; }
|
|
222
|
+
.wp-wl__logo { display: block; }
|
|
223
|
+
.wp-wl__canvas { position: absolute; pointer-events: none; }
|
|
224
|
+
.wp-wl[data-drawn="true"] .wp-wl__logo { visibility: hidden; }
|
|
225
|
+
|
|
234
226
|
@property --wp-wl-o { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
235
227
|
@property --wp-wl-wo { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
236
228
|
@property --wp-wl-wb { syntax: "<length>"; inherits: false; initial-value: 0px; }
|
|
237
229
|
|
|
238
|
-
.wp-wl__star, .wp-wl__dot, .wp-wl__hop { transform-box: fill-box; transform-origin: center; }
|
|
239
|
-
.wp-wl__vb { transform-box: view-box; transform-origin: 512px 512px; }
|
|
240
|
-
|
|
241
|
-
/* Typing stars: each hops in turn. */
|
|
242
|
-
.wp-wl__hop {
|
|
243
|
-
transform: translateY(var(--wp-wl-x)) scale(var(--wp-wl-s));
|
|
244
|
-
animation: wp-wl-hop var(--wp-wl-t) ease-in-out infinite;
|
|
245
|
-
animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * 0.15);
|
|
246
|
-
}
|
|
247
|
-
@keyframes wp-wl-hop {
|
|
248
|
-
0%, 40%, 100% { --wp-wl-x: 0px; --wp-wl-s: 1; }
|
|
249
|
-
20% { --wp-wl-x: -110px; --wp-wl-s: 1.12; }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/* Shuffle: the small stars swap places, the big one dips and turns. */
|
|
253
|
-
.wp-wl__swap {
|
|
254
|
-
transform: rotate(var(--wp-wl-r));
|
|
255
|
-
animation: wp-wl-swap calc(var(--wp-wl-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
256
|
-
}
|
|
257
|
-
.wp-wl__dip {
|
|
258
|
-
transform: rotate(var(--wp-wl-r)) scale(var(--wp-wl-s));
|
|
259
|
-
animation: wp-wl-dip var(--wp-wl-t) cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
260
|
-
}
|
|
261
|
-
@keyframes wp-wl-swap {
|
|
262
|
-
0%, 15% { --wp-wl-r: 0deg; }
|
|
263
|
-
50%, 65% { --wp-wl-r: 180deg; }
|
|
264
|
-
100% { --wp-wl-r: 360deg; }
|
|
265
|
-
}
|
|
266
|
-
@keyframes wp-wl-dip {
|
|
267
|
-
0%, 15% { --wp-wl-s: 1; --wp-wl-r: 0deg; }
|
|
268
|
-
32% { --wp-wl-s: 0.7; --wp-wl-r: 45deg; }
|
|
269
|
-
50%, 100% { --wp-wl-s: 1; --wp-wl-r: 90deg; }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/* Orbit: the four nodes circle the stars and twinkle in turn. */
|
|
273
|
-
.wp-wl__spin {
|
|
274
|
-
transform: rotate(var(--wp-wl-r));
|
|
275
|
-
animation: wp-wl-spin calc(var(--wp-wl-t) * 2) cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
|
|
276
|
-
}
|
|
277
|
-
.wp-wl__dot {
|
|
278
|
-
opacity: var(--wp-wl-o);
|
|
279
|
-
transform: scale(var(--wp-wl-s));
|
|
280
|
-
animation: wp-wl-twinkle var(--wp-wl-t) ease-in-out infinite;
|
|
281
|
-
animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * -0.25);
|
|
282
|
-
}
|
|
283
|
-
.wp-wl__breathe {
|
|
284
|
-
transform: scale(var(--wp-wl-s));
|
|
285
|
-
animation: wp-wl-breathe var(--wp-wl-t) ease-in-out infinite;
|
|
286
|
-
}
|
|
287
|
-
@keyframes wp-wl-spin { from { --wp-wl-r: 0deg; } to { --wp-wl-r: 360deg; } }
|
|
288
|
-
@keyframes wp-wl-twinkle {
|
|
289
|
-
0%, 100% { --wp-wl-o: 1; --wp-wl-s: 1; }
|
|
290
|
-
50% { --wp-wl-o: 0.35; --wp-wl-s: 0.6; }
|
|
291
|
-
}
|
|
292
|
-
@keyframes wp-wl-breathe { 0%, 100% { --wp-wl-s: 1; } 50% { --wp-wl-s: 0.86; } }
|
|
293
|
-
|
|
294
|
-
/* Burst: the small stars fly out of the big one and back. `--wp-wl-f` is
|
|
295
|
-
* how far out they are (1 = at --wp-wl-dx/dy, 0 = home). */
|
|
296
|
-
.wp-wl__burst {
|
|
297
|
-
opacity: var(--wp-wl-o);
|
|
298
|
-
transform:
|
|
299
|
-
translate(calc(var(--wp-wl-dx) * var(--wp-wl-f)), calc(var(--wp-wl-dy) * var(--wp-wl-f)))
|
|
300
|
-
scale(var(--wp-wl-s));
|
|
301
|
-
animation: wp-wl-burst var(--wp-wl-t) cubic-bezier(0.3, 0, 0.2, 1) infinite;
|
|
302
|
-
}
|
|
303
|
-
.wp-wl__pop {
|
|
304
|
-
transform: scale(var(--wp-wl-s));
|
|
305
|
-
animation: wp-wl-pop var(--wp-wl-t) ease-in-out infinite;
|
|
306
|
-
}
|
|
307
|
-
@keyframes wp-wl-burst {
|
|
308
|
-
0% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
|
|
309
|
-
15% { --wp-wl-o: 1; }
|
|
310
|
-
45%, 75% { --wp-wl-f: 0; --wp-wl-s: 1.15; --wp-wl-o: 1; }
|
|
311
|
-
100% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
|
|
312
|
-
}
|
|
313
|
-
@keyframes wp-wl-pop { 0%, 100% { --wp-wl-s: 1.1; } 40% { --wp-wl-s: 0.8; } }
|
|
314
|
-
|
|
315
230
|
@media (prefers-reduced-motion: reduce) {
|
|
316
|
-
.wp-wl__logo * { animation: none !important; }
|
|
317
231
|
.wp-wl__logo { opacity: var(--wp-wl-o); animation: wp-wl-fade 2.4s ease-in-out infinite; }
|
|
318
232
|
@keyframes wp-wl-fade { 0%, 100% { --wp-wl-o: 1; } 50% { --wp-wl-o: 0.55; } }
|
|
319
233
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Looping marks drawn on a canvas (LoadingMark, WorkingLine).
|
|
3
|
+
*
|
|
4
|
+
* A keyframed `transform` / `opacity` on an SVG path goes to the compositor,
|
|
5
|
+
* which blurs the path and snaps it to whole pixels as it starts. Animating
|
|
6
|
+
* registered custom properties fixed that in Chromium, but Firefox still lays
|
|
7
|
+
* each frame of an SVG transform on the pixel grid, so small marks stepped
|
|
8
|
+
* and twitched as they moved. A canvas puts every shape at its exact
|
|
9
|
+
* fractional position each frame, sharp and smooth in both.
|
|
10
|
+
*
|
|
11
|
+
* One `requestAnimationFrame` loop draws every mark on the page. Each mark
|
|
12
|
+
* keeps its own clock, so a host can slow or pause the marks inside it with
|
|
13
|
+
* `data-wp-motion-speed` (a number) and `data-wp-motion-paused` on an
|
|
14
|
+
* ancestor, as the kit site's Animations section does.
|
|
15
|
+
*
|
|
16
|
+
* A mark keeps its still SVG for the first paint, under reduced motion and
|
|
17
|
+
* in a test DOM; `useCanvasMotion` puts the canvas over it once it can draw.
|
|
18
|
+
*/
|
|
19
|
+
import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from "vue";
|
|
20
|
+
|
|
21
|
+
/** Room around the 1024 box for parts that move past its edge, and for
|
|
22
|
+
* anti-aliasing there. */
|
|
23
|
+
export const PAD = 82;
|
|
24
|
+
export const BOX = 1024 + 2 * PAD;
|
|
25
|
+
|
|
26
|
+
/** CSS `cubic-bezier(x1, y1, x2, y2)` as a function of progress. */
|
|
27
|
+
export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x: number) => number {
|
|
28
|
+
return (x) => {
|
|
29
|
+
if (x <= 0) return 0;
|
|
30
|
+
if (x >= 1) return 1;
|
|
31
|
+
let lo = 0;
|
|
32
|
+
let hi = 1;
|
|
33
|
+
let t = x;
|
|
34
|
+
for (let k = 0; k < 30; k++) {
|
|
35
|
+
t = (lo + hi) / 2;
|
|
36
|
+
const cx = 3 * x1 * t * (1 - t) ** 2 + 3 * x2 * t * t * (1 - t) + t ** 3;
|
|
37
|
+
if (cx < x) lo = t;
|
|
38
|
+
else hi = t;
|
|
39
|
+
}
|
|
40
|
+
return 3 * y1 * t * (1 - t) ** 2 + 3 * y2 * t * t * (1 - t) + t ** 3;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const EASE = cubicBezier(0.25, 0.1, 0.25, 1);
|
|
45
|
+
export const EASE_IN_OUT = cubicBezier(0.42, 0, 0.58, 1);
|
|
46
|
+
export const SMOOTH = cubicBezier(0.65, 0, 0.35, 1);
|
|
47
|
+
|
|
48
|
+
export type Stop = readonly [at: number, values: readonly number[]];
|
|
49
|
+
|
|
50
|
+
/** Value of a keyframe track at progress `p` (0..1), eased per segment the
|
|
51
|
+
* way CSS eases between two keyframes. */
|
|
52
|
+
export function track(stops: readonly Stop[], p: number, ease: (x: number) => number): number[] {
|
|
53
|
+
for (let k = 0; k < stops.length - 1; k++) {
|
|
54
|
+
const a = stops[k];
|
|
55
|
+
const b = stops[k + 1];
|
|
56
|
+
if (!a || !b) break;
|
|
57
|
+
if (p >= a[0] && p <= b[0]) {
|
|
58
|
+
if (b[0] === a[0]) return [...b[1]];
|
|
59
|
+
const e = ease((p - a[0]) / (b[0] - a[0]));
|
|
60
|
+
return a[1].map((v, j) => v + ((b[1][j] ?? v) - v) * e);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...(stops[stops.length - 1]?.[1] ?? [])];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Progress (0..1) through a loop of `period` ms, `ms` into the animation.
|
|
67
|
+
* A positive `delay` holds the start pose until it has passed, like CSS. */
|
|
68
|
+
export function loop(ms: number, period: number, delay = 0): number {
|
|
69
|
+
const t = ms - delay;
|
|
70
|
+
if (t < 0) return 0;
|
|
71
|
+
return (t % period) / period;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Mixes two `#rrggbb` colours in sRGB, like `color-mix(in srgb, a w%, b)`. */
|
|
75
|
+
export function mix(a: string, b: string, weightA: number): string {
|
|
76
|
+
const ch = (hex: string, i: number): number => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
|
|
77
|
+
const out = [0, 1, 2].map((i) => Math.round(ch(a, i) * weightA + ch(b, i) * (1 - weightA)));
|
|
78
|
+
return `#${out.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Fills `path` scaled and turned around `[x, y]`. */
|
|
82
|
+
export function fillAround(
|
|
83
|
+
ctx: CanvasRenderingContext2D,
|
|
84
|
+
[x, y]: readonly [number, number],
|
|
85
|
+
scale: number,
|
|
86
|
+
turn: number,
|
|
87
|
+
path: Path2D,
|
|
88
|
+
fill: string | CanvasGradient,
|
|
89
|
+
alpha = 1,
|
|
90
|
+
): void {
|
|
91
|
+
ctx.save();
|
|
92
|
+
ctx.translate(x, y);
|
|
93
|
+
ctx.rotate((turn * Math.PI) / 180);
|
|
94
|
+
ctx.scale(scale, scale);
|
|
95
|
+
ctx.translate(-x, -y);
|
|
96
|
+
ctx.globalAlpha = alpha;
|
|
97
|
+
ctx.fillStyle = fill;
|
|
98
|
+
ctx.fill(path);
|
|
99
|
+
ctx.restore();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Clears the canvas and sets it up to draw in the 1024 box. */
|
|
103
|
+
export function begin(ctx: CanvasRenderingContext2D, width: number): void {
|
|
104
|
+
const k = width / BOX;
|
|
105
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
106
|
+
ctx.clearRect(0, 0, width, width);
|
|
107
|
+
ctx.setTransform(k, 0, 0, k, PAD * k, PAD * k);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Draws one frame `ms` into the animation on a square canvas `width` px wide. */
|
|
111
|
+
export type Draw = (ctx: CanvasRenderingContext2D, width: number, ms: number) => void;
|
|
112
|
+
|
|
113
|
+
/** A mark the loop draws. */
|
|
114
|
+
export interface Animated {
|
|
115
|
+
canvas: HTMLCanvasElement;
|
|
116
|
+
draw: Draw;
|
|
117
|
+
/** ms of animation shown so far, advanced by the loop. */
|
|
118
|
+
clock: number;
|
|
119
|
+
/** False while off screen: the clock still runs, nothing is drawn. */
|
|
120
|
+
visible: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const marks = new Set<Animated>();
|
|
124
|
+
let frame = 0;
|
|
125
|
+
let last = 0;
|
|
126
|
+
|
|
127
|
+
/** Speed and pause from the nearest host that sets them. */
|
|
128
|
+
export function motionOf(el: Element): { speed: number; paused: boolean } {
|
|
129
|
+
const paused = el.closest("[data-wp-motion-paused]") !== null;
|
|
130
|
+
const speedHost = el.closest("[data-wp-motion-speed]");
|
|
131
|
+
const speed = Number(speedHost?.getAttribute("data-wp-motion-speed") ?? "1");
|
|
132
|
+
return { speed: Number.isFinite(speed) && speed >= 0 ? speed : 1, paused };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fit(c: HTMLCanvasElement): void {
|
|
136
|
+
const want = Math.max(1, Math.round(c.clientWidth * (window.devicePixelRatio || 1)));
|
|
137
|
+
if (c.width !== want) {
|
|
138
|
+
c.width = want;
|
|
139
|
+
c.height = want;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function paint(m: Animated): boolean {
|
|
144
|
+
const ctx = m.canvas.getContext("2d");
|
|
145
|
+
if (!ctx) return false;
|
|
146
|
+
fit(m.canvas);
|
|
147
|
+
m.draw(ctx, m.canvas.width, m.clock);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function tick(now: number): void {
|
|
152
|
+
const dt = last ? Math.min(now - last, 100) : 0;
|
|
153
|
+
last = now;
|
|
154
|
+
for (const m of marks) {
|
|
155
|
+
const { speed, paused } = motionOf(m.canvas);
|
|
156
|
+
if (!paused) m.clock += dt * speed;
|
|
157
|
+
if (m.visible) paint(m);
|
|
158
|
+
}
|
|
159
|
+
frame = marks.size ? requestAnimationFrame(tick) : 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Starts drawing a mark; returns the function that stops it. */
|
|
163
|
+
export function animate(mark: Animated): () => void {
|
|
164
|
+
marks.add(mark);
|
|
165
|
+
if (!frame) {
|
|
166
|
+
last = 0;
|
|
167
|
+
frame = requestAnimationFrame(tick);
|
|
168
|
+
}
|
|
169
|
+
return () => {
|
|
170
|
+
marks.delete(mark);
|
|
171
|
+
if (!marks.size && frame) {
|
|
172
|
+
cancelAnimationFrame(frame);
|
|
173
|
+
frame = 0;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Draws one frame right away (before the loop runs, so the mark never
|
|
179
|
+
* shows blank). False when there is no 2D canvas. */
|
|
180
|
+
export function drawNow(mark: Animated): boolean {
|
|
181
|
+
return paint(mark);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Inline style for a canvas over a `px`-wide mark, reaching PAD past it. */
|
|
185
|
+
export function canvasBox(px: number): Record<string, string> {
|
|
186
|
+
const side = (px * BOX) / 1024;
|
|
187
|
+
const off = (px * PAD) / 1024;
|
|
188
|
+
return { width: `${side}px`, height: `${side}px`, top: `${-off}px`, left: `${-off}px` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs a mark's canvas while motion is allowed. Render
|
|
193
|
+
* `<canvas v-if="live" ref="canvas">` over the still SVG, and hide the SVG
|
|
194
|
+
* while `drawn`.
|
|
195
|
+
*/
|
|
196
|
+
export function useCanvasMotion(canvas: Ref<HTMLCanvasElement | null>, draw: Draw): { live: Ref<boolean>; drawn: Ref<boolean> } {
|
|
197
|
+
const live = ref(false);
|
|
198
|
+
const drawn = ref(false);
|
|
199
|
+
let stop: (() => void) | null = null;
|
|
200
|
+
let observer: IntersectionObserver | null = null;
|
|
201
|
+
let motion: MediaQueryList | null = null;
|
|
202
|
+
|
|
203
|
+
async function start(): Promise<void> {
|
|
204
|
+
if (stop) return;
|
|
205
|
+
live.value = true;
|
|
206
|
+
await nextTick();
|
|
207
|
+
const el = canvas.value;
|
|
208
|
+
if (!el || !live.value) return;
|
|
209
|
+
const mark: Animated = { canvas: el, draw, clock: 0, visible: true };
|
|
210
|
+
if (!drawNow(mark)) {
|
|
211
|
+
live.value = false;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
drawn.value = true;
|
|
215
|
+
stop = animate(mark);
|
|
216
|
+
if (typeof IntersectionObserver === "function") {
|
|
217
|
+
observer = new IntersectionObserver((entries) => {
|
|
218
|
+
for (const e of entries) mark.visible = e.isIntersecting;
|
|
219
|
+
});
|
|
220
|
+
observer.observe(el);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function halt(): void {
|
|
224
|
+
stop?.();
|
|
225
|
+
stop = null;
|
|
226
|
+
observer?.disconnect();
|
|
227
|
+
observer = null;
|
|
228
|
+
live.value = false;
|
|
229
|
+
drawn.value = false;
|
|
230
|
+
}
|
|
231
|
+
function onMotionChange(): void {
|
|
232
|
+
if (motion?.matches) halt();
|
|
233
|
+
else void start();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
onMounted(() => {
|
|
237
|
+
// No matchMedia means no way to honour reduced motion, and a test DOM
|
|
238
|
+
// (jsdom) has no canvas and logs an error for every attempt: keep the
|
|
239
|
+
// still SVG in both.
|
|
240
|
+
if (typeof window.matchMedia !== "function" || typeof requestAnimationFrame !== "function") return;
|
|
241
|
+
if (navigator.userAgent.includes("jsdom")) return;
|
|
242
|
+
motion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
243
|
+
motion.addEventListener?.("change", onMotionChange);
|
|
244
|
+
if (!motion.matches) void start();
|
|
245
|
+
});
|
|
246
|
+
onBeforeUnmount(() => {
|
|
247
|
+
motion?.removeEventListener?.("change", onMotionChange);
|
|
248
|
+
halt();
|
|
249
|
+
});
|
|
250
|
+
return { live, drawn };
|
|
251
|
+
}
|
package/src/kind-colour.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The colour of each module type, for a Chip (or anything else) that names
|
|
3
3
|
* one: the `--wp-kind-*` colour the extension uses for that type, bundles
|
|
4
|
-
* slate. There is no kind chip: a kind is a Chip
|
|
4
|
+
* slate, and its icon (`kindIcon`). There is no kind chip: a kind is a Chip
|
|
5
|
+
* with this colour.
|
|
5
6
|
*
|
|
6
7
|
* <Chip :color="kindColour(itemType(post.kind, post.subtype))">{{ itemType(post.kind, post.subtype) }}</Chip>
|
|
7
8
|
*/
|
|
@@ -26,3 +27,25 @@ export function itemType(kind: "module" | "bundle", subtype?: ItemType | null):
|
|
|
26
27
|
export function kindColour(type: ItemType): string {
|
|
27
28
|
return COLOURS[type];
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/** The PrimeIcons name (for Icon / ListRow `icon`) of each item type: the
|
|
32
|
+
* same icons the extension's kind-icons map uses. */
|
|
33
|
+
const ICONS: Record<ItemType, string> = {
|
|
34
|
+
wildcard: "sparkles",
|
|
35
|
+
fixed_values: "tag",
|
|
36
|
+
combine: "link",
|
|
37
|
+
derivation: "arrow-right-arrow-left",
|
|
38
|
+
constraint: "filter",
|
|
39
|
+
bundle: "box",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The icon of a type read from a payload (any string): `circle` for a type
|
|
43
|
+
* the kit doesn't know. */
|
|
44
|
+
export function kindIcon(type: string): string {
|
|
45
|
+
return (ICONS as Record<string, string>)[type] ?? "circle";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A payload's type string as a module subtype, or null when it isn't one. */
|
|
49
|
+
export function moduleSubtype(type: string): ModuleSubtype | null {
|
|
50
|
+
return type === "fixed_values" || type === "combine" || type === "wildcard" || type === "constraint" || type === "derivation" ? type : null;
|
|
51
|
+
}
|
package/src/linkify.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free, pure rich-text segmenter for prose (post descriptions).
|
|
3
|
+
*
|
|
4
|
+
* Splits a plain-text string into an ordered list of {@link RichSeg}s so the
|
|
5
|
+
* renderer (`RichText.vue`) can paint each piece as a Vue node, never via
|
|
6
|
+
* `v-html`. Because every segment is interpolated as a text node
|
|
7
|
+
* or a plain `<a>`/`<code>` with attribute-bound values, the output is
|
|
8
|
+
* XSS-safe by construction: a `<script>` in the input is just text.
|
|
9
|
+
*
|
|
10
|
+
* A single left-to-right scan recognises, in priority order:
|
|
11
|
+
* 1. `@{...}` wildcard-ref code token (e.g. `@{color:cool and dark}`)
|
|
12
|
+
* 2. `$name(.field)` var/accessor code token (e.g. `$color`, `$color.2`)
|
|
13
|
+
* 3. `https?://…` explicit URL
|
|
14
|
+
* 4. bare URL a `domain.tld/path` or `www.…` (must carry a `/path`)
|
|
15
|
+
* Everything else accumulates as literal text. Adjacent text runs collapse
|
|
16
|
+
* into one `text` segment.
|
|
17
|
+
*
|
|
18
|
+
* URL handling:
|
|
19
|
+
* - Trailing punctuation `.,;:!?)` is peeled off the match and pushed back
|
|
20
|
+
* as text, so `(see https://x.com).` doesn't swallow the `).`.
|
|
21
|
+
* - http(s) matches keep their scheme as the href; bare/`www.` matches are
|
|
22
|
+
* prefixed with `https://`.
|
|
23
|
+
* - A `link` segment is emitted ONLY when the resulting href starts with
|
|
24
|
+
* `http://` or `https://`; anything else degrades to text.
|
|
25
|
+
*
|
|
26
|
+
* Lossless invariant: concatenating every `.text` reproduces the input.
|
|
27
|
+
*
|
|
28
|
+
* Why not rich-tokenize.ts: that is the wildcard GRAMMAR lexer (picks,
|
|
29
|
+
* escapes, `@{8hex}` refs only), and a description is prose: `{a|b}` must
|
|
30
|
+
* stay text, `$$` is just two dollars, and a mention like `@{color:cool}`
|
|
31
|
+
* names a wildcard in words. This scanner only marks mentions and links.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type RichSeg = {
|
|
35
|
+
kind: "text" | "link" | "code";
|
|
36
|
+
text: string;
|
|
37
|
+
/** Present only on `link` segments — always an `http(s)://` absolute URL. */
|
|
38
|
+
href?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Code token: `@{...}` (ref). Non-greedy body, no nested `}`.
|
|
42
|
+
const RE_REF = /^@\{[^}]*\}/;
|
|
43
|
+
// Code token: `$name` with an optional single `.field` accessor.
|
|
44
|
+
const RE_VAR = /^\$[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)?/;
|
|
45
|
+
// Explicit URL with scheme — runs to the next whitespace.
|
|
46
|
+
const RE_URL = /^https?:\/\/[^\s]+/;
|
|
47
|
+
// Bare URL: either a `www.` host, OR a `domain.tld/` (a dotted host directly
|
|
48
|
+
// followed by a `/path`). Requires the `/` so prose like `e.g.` or `node.com`
|
|
49
|
+
// without a path is NOT linkified (kept conservative on purpose).
|
|
50
|
+
const RE_BARE = /^(?:www\.|[a-z0-9-]+(?:\.[a-z0-9-]+)+\/)[^\s]+/i;
|
|
51
|
+
|
|
52
|
+
/** Trailing punctuation peeled off a matched URL and returned as text. */
|
|
53
|
+
const TRAILING = new Set([".", ",", ";", ":", "!", "?", ")"]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Segment `input` into ordered text / link / code runs. Returns an empty
|
|
57
|
+
* array for empty input; the caller decides what to render for `[]`.
|
|
58
|
+
*/
|
|
59
|
+
export function parseRichText(input: string): RichSeg[] {
|
|
60
|
+
if (!input) return [];
|
|
61
|
+
|
|
62
|
+
const segs: RichSeg[] = [];
|
|
63
|
+
let buf = "";
|
|
64
|
+
|
|
65
|
+
/** Flush the pending literal-text run (if any) as one `text` segment. */
|
|
66
|
+
const flush = (): void => {
|
|
67
|
+
if (buf) {
|
|
68
|
+
segs.push({ kind: "text", text: buf });
|
|
69
|
+
buf = "";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let i = 0;
|
|
74
|
+
const n = input.length;
|
|
75
|
+
while (i < n) {
|
|
76
|
+
const rest = input.slice(i);
|
|
77
|
+
const ch = input[i];
|
|
78
|
+
|
|
79
|
+
// 1. @{...} ref code token.
|
|
80
|
+
if (ch === "@") {
|
|
81
|
+
const m = rest.match(RE_REF);
|
|
82
|
+
if (m) {
|
|
83
|
+
flush();
|
|
84
|
+
segs.push({ kind: "code", text: m[0] });
|
|
85
|
+
i += m[0].length;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. $var code token.
|
|
91
|
+
if (ch === "$") {
|
|
92
|
+
const m = rest.match(RE_VAR);
|
|
93
|
+
if (m) {
|
|
94
|
+
flush();
|
|
95
|
+
segs.push({ kind: "code", text: m[0] });
|
|
96
|
+
i += m[0].length;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3 + 4. URLs — explicit scheme first, then bare/`www.`.
|
|
102
|
+
//
|
|
103
|
+
// A bare match is only attempted at a word boundary (start of input or
|
|
104
|
+
// after whitespace) so we don't linkify the tail of a longer token.
|
|
105
|
+
const atBoundary = i === 0 || /\s/.test(input[i - 1] ?? "");
|
|
106
|
+
const urlMatch =
|
|
107
|
+
rest.match(RE_URL) ?? (atBoundary ? rest.match(RE_BARE) : null);
|
|
108
|
+
if (urlMatch) {
|
|
109
|
+
let raw = urlMatch[0];
|
|
110
|
+
// Peel trailing punctuation back into the text stream.
|
|
111
|
+
let trailer = "";
|
|
112
|
+
let last = raw.slice(-1);
|
|
113
|
+
while (last && TRAILING.has(last)) {
|
|
114
|
+
trailer = last + trailer;
|
|
115
|
+
raw = raw.slice(0, -1);
|
|
116
|
+
last = raw.slice(-1);
|
|
117
|
+
}
|
|
118
|
+
if (raw.length > 0) {
|
|
119
|
+
const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
120
|
+
// Only emit a link when the href is genuinely http(s); otherwise the
|
|
121
|
+
// whole match degrades to text (defensive — the prefix above always
|
|
122
|
+
// satisfies this, but keeps the contract explicit).
|
|
123
|
+
if (/^https?:\/\//i.test(href)) {
|
|
124
|
+
flush();
|
|
125
|
+
segs.push({ kind: "link", text: raw, href });
|
|
126
|
+
} else {
|
|
127
|
+
buf += raw;
|
|
128
|
+
}
|
|
129
|
+
if (trailer) buf += trailer;
|
|
130
|
+
i += raw.length + trailer.length;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Default: accumulate one literal character.
|
|
136
|
+
buf += ch;
|
|
137
|
+
i += 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
flush();
|
|
141
|
+
return segs;
|
|
142
|
+
}
|