@dumiflex/wp-kit 0.4.0 → 0.6.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 +47 -0
- package/README.md +5 -0
- package/package.json +4 -3
- package/src/Avatar.vue +1 -1
- package/src/Banner.vue +4 -4
- package/src/Button.vue +73 -44
- package/src/Checkbox.vue +201 -63
- package/src/Chip.vue +5 -2
- package/src/CodeBlock.vue +33 -6
- package/src/ColorField.vue +60 -6
- package/src/Combobox.vue +10 -14
- package/src/ConfirmModal.vue +12 -22
- package/src/ContextMenu.vue +11 -30
- package/src/CopyButton.vue +1 -1
- package/src/DataTable.vue +3 -4
- package/src/DatePicker.vue +1 -1
- package/src/Disclosure.vue +2 -1
- package/src/Drawer.vue +2 -2
- package/src/DropZone.vue +2 -2
- package/src/HsvPicker.vue +2 -2
- package/src/IconPicker.vue +1 -1
- package/src/ImageGrid.vue +17 -15
- package/src/Lightbox.vue +6 -3
- package/src/LoadingMark.vue +93 -51
- package/src/Modal.vue +109 -44
- package/src/ModalShell.vue +26 -5
- package/src/Pagination.vue +4 -4
- package/src/Popover.vue +68 -12
- package/src/RadioGroup.vue +5 -4
- package/src/SearchInput.vue +1 -1
- package/src/Select.vue +53 -158
- package/src/Skeleton.vue +21 -5
- package/src/Slider.vue +166 -55
- package/src/SortableList.vue +1 -1
- package/src/TagInput.vue +1 -1
- package/src/Timeline.vue +2 -2
- package/src/WorkingLine.vue +320 -0
- package/src/WpIconButton.vue +3 -2
- package/src/WpImageZoom.vue +7 -7
- package/src/WpSegmented.vue +3 -1
- package/src/WpSwitch.vue +1 -1
- package/src/WpTabs.vue +1 -1
- package/src/_modal-motion.css +1 -1
- package/src/button.css +8 -6
- package/src/floating.ts +5 -0
- package/src/highlight.ts +199 -0
- package/src/hsv.ts +1 -1
- package/src/loading-mark-motion.ts +249 -0
- package/src/menu.css +51 -0
- package/src/motion.css +73 -0
- package/src/slider-math.ts +45 -1
- package/src/theme/looks.css +6 -19
- package/src/theme/tokens.css +12 -1108
- package/src/tokens.ts +8 -3
- package/src/tooltip.css +8 -5
- package/src/useDialogFocus.ts +11 -2
- package/src/CloseButton.vue +0 -69
- package/src/ColorPicker.vue +0 -201
- package/src/ConfirmDialog.vue +0 -165
- package/src/RangeSlider.vue +0 -183
- package/src/Toggle.vue +0 -36
- package/src/WpButton.vue +0 -75
- package/src/WpCheck.vue +0 -104
- package/src/_skeleton.css +0 -50
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* WorkingLine: "the model is working", in one line like Claude Code's. A
|
|
4
|
+
* small mark drawn from the logo's sparkles, a word that crossfades when it
|
|
5
|
+
* changes, and the time so far. It only draws: the app decides the word,
|
|
6
|
+
* the time and the tempo (the extension's AiWorking cycles playful words
|
|
7
|
+
* and the real status, and reads the request's phase).
|
|
8
|
+
*
|
|
9
|
+
* <WorkingLine word="Thinking" time="12s" tempo="mid" />
|
|
10
|
+
* <WorkingLine :word="word" :word-key="phase" variant="orbit" inline />
|
|
11
|
+
*
|
|
12
|
+
* Marks (`variant`): typing (three stars hop in turn), shuffle (the small
|
|
13
|
+
* stars swap places round the big one), orbit (the logo's four nodes circle
|
|
14
|
+
* the stars) or burst (the small stars fly out of the big one and back).
|
|
15
|
+
* `tempo` sets their speed: slow while the model reads, mid while it thinks,
|
|
16
|
+
* fast while it writes.
|
|
17
|
+
*
|
|
18
|
+
* The word crossfades in place when `wordKey` changes (default: the word),
|
|
19
|
+
* so a token count ticking up can keep its key and not flicker. Its box is
|
|
20
|
+
* never narrower than the word: it widens at once and narrows smoothly once
|
|
21
|
+
* the old word has mostly faded.
|
|
22
|
+
*
|
|
23
|
+
* Every mark animates registered custom properties, never `transform` or
|
|
24
|
+
* `opacity` themselves, so the paths repaint sharp each frame instead of
|
|
25
|
+
* going to a compositor layer that blurs them and snaps them to whole pixels.
|
|
26
|
+
*/
|
|
27
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
28
|
+
import { BRAND_COLOURS, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
|
|
29
|
+
|
|
30
|
+
export type WorkingVariant = "typing" | "shuffle" | "orbit" | "burst";
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
/** What is shown, without the "…" (the line adds it). */
|
|
34
|
+
word: string;
|
|
35
|
+
/** What the crossfade keys on; defaults to the word. */
|
|
36
|
+
wordKey?: string | undefined;
|
|
37
|
+
/** Time so far, already formatted ("42s"). Left out, no time shows. */
|
|
38
|
+
time?: string | undefined;
|
|
39
|
+
variant?: WorkingVariant | undefined;
|
|
40
|
+
tempo?: "slow" | "mid" | "fast" | undefined;
|
|
41
|
+
/** The mark's size in px. */
|
|
42
|
+
size?: number | undefined;
|
|
43
|
+
/** Smaller text and a capped word width, for a top bar. */
|
|
44
|
+
inline?: boolean | undefined;
|
|
45
|
+
/** The whole status, for screen readers and the tooltip; defaults to the word. */
|
|
46
|
+
label?: string | undefined;
|
|
47
|
+
brand?: LogoBrand | undefined;
|
|
48
|
+
}>(), { variant: "typing", tempo: "slow", size: 22, inline: false, brand: "extension" });
|
|
49
|
+
|
|
50
|
+
const key = computed(() => props.wordKey ?? props.word);
|
|
51
|
+
const name = computed(() => props.label ?? props.word);
|
|
52
|
+
|
|
53
|
+
const colours = computed(() => BRAND_COLOURS[props.brand]);
|
|
54
|
+
const BIG = computed(() => colours.value[0]);
|
|
55
|
+
const A = computed(() => colours.value[1]);
|
|
56
|
+
const B = computed(() => colours.value[2]);
|
|
57
|
+
/** 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);
|
|
64
|
+
/** Typing stars: the big sparkle drawn small three times in a row. */
|
|
65
|
+
const typing = computed(() => [[250, A.value], [512, BIG.value], [774, B.value]] as const);
|
|
66
|
+
|
|
67
|
+
const measure = ref<HTMLElement | null>(null);
|
|
68
|
+
const boxWidth = ref<number | null>(null);
|
|
69
|
+
const growing = ref(false);
|
|
70
|
+
async function fit(): Promise<void> {
|
|
71
|
+
await nextTick();
|
|
72
|
+
// Rounded up, plus a pixel to spare: one pixel short is enough for the
|
|
73
|
+
// browser to swap the word's end for its own "…", which Firefox draws
|
|
74
|
+
// invisibly on this gradient text.
|
|
75
|
+
const raw = measure.value?.getBoundingClientRect().width ?? 0;
|
|
76
|
+
const w = raw ? Math.ceil(raw) + 1 : 0;
|
|
77
|
+
if (!w || w === boxWidth.value) return;
|
|
78
|
+
growing.value = boxWidth.value === null || w > boxWidth.value;
|
|
79
|
+
boxWidth.value = w;
|
|
80
|
+
}
|
|
81
|
+
watch(() => props.word, () => { void fit(); });
|
|
82
|
+
|
|
83
|
+
// The word's width can also change on its own, when the font loads late.
|
|
84
|
+
let resized: ResizeObserver | null = null;
|
|
85
|
+
onMounted(() => {
|
|
86
|
+
void fit();
|
|
87
|
+
if (typeof ResizeObserver !== "undefined" && measure.value) {
|
|
88
|
+
resized = new ResizeObserver(() => { void fit(); });
|
|
89
|
+
resized.observe(measure.value);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
onBeforeUnmount(() => resized?.disconnect());
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<div
|
|
97
|
+
class="wp-wl"
|
|
98
|
+
:data-tempo="tempo"
|
|
99
|
+
:data-variant="variant"
|
|
100
|
+
:data-inline="inline ? 'true' : undefined"
|
|
101
|
+
role="status"
|
|
102
|
+
:aria-label="name"
|
|
103
|
+
:title="name"
|
|
104
|
+
:style="{ '--wp-wl-big': BIG, '--wp-wl-a': A, '--wp-wl-b': B }"
|
|
105
|
+
>
|
|
106
|
+
<svg class="wp-wl__logo" viewBox="0 0 1024 1024" :width="size" :height="size" aria-hidden="true">
|
|
107
|
+
<template v-if="variant === 'typing'">
|
|
108
|
+
<g v-for="([x, c], i) in typing" :key="i" class="wp-wl__hop" :style="{ '--wp-wl-k': i }">
|
|
109
|
+
<path class="wp-wl__star" :style="{ fill: c }" :transform="`translate(${x - 256} 256) scale(.5)`" :d="STAR_BIG" />
|
|
110
|
+
</g>
|
|
111
|
+
</template>
|
|
112
|
+
<g v-else-if="variant === 'shuffle'" transform="translate(512 512) scale(1.45) translate(-512 -512)">
|
|
113
|
+
<path class="wp-wl__star wp-wl__star--big wp-wl__dip" :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
114
|
+
<g class="wp-wl__vb wp-wl__swap">
|
|
115
|
+
<path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
|
|
116
|
+
<path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
|
|
117
|
+
</g>
|
|
118
|
+
</g>
|
|
119
|
+
<template v-else-if="variant === 'orbit'">
|
|
120
|
+
<g class="wp-wl__vb wp-wl__spin">
|
|
121
|
+
<circle
|
|
122
|
+
v-for="([x, y, c], i) in nodes" :key="i" class="wp-wl__dot"
|
|
123
|
+
:style="{ '--wp-wl-k': i, fill: c }" :cx="x" :cy="y" r="70"
|
|
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" />
|
|
129
|
+
</template>
|
|
130
|
+
<g v-else transform="translate(512 512) scale(1.3) translate(-512 -512)">
|
|
131
|
+
<path class="wp-wl__star wp-wl__star--big wp-wl__pop" :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
132
|
+
<path class="wp-wl__star wp-wl__burst" :style="{ fill: A, '--wp-wl-dx': '152px', '--wp-wl-dy': '-152px' }" :d="STAR_A" />
|
|
133
|
+
<path class="wp-wl__star wp-wl__burst" :style="{ fill: B, '--wp-wl-dx': '-152px', '--wp-wl-dy': '152px' }" :d="STAR_B" />
|
|
134
|
+
</g>
|
|
135
|
+
</svg>
|
|
136
|
+
<span
|
|
137
|
+
class="wp-wl__words"
|
|
138
|
+
:data-grow="growing ? 'true' : undefined"
|
|
139
|
+
:style="boxWidth ? { width: `${boxWidth}px` } : undefined"
|
|
140
|
+
>
|
|
141
|
+
<Transition name="wp-wl-word" type="transition">
|
|
142
|
+
<span :key="key" class="wp-wl__word" data-test="working-line-word">{{ word }}…</span>
|
|
143
|
+
</Transition>
|
|
144
|
+
<span ref="measure" class="wp-wl__word wp-wl__measure" aria-hidden="true">{{ word }}…</span>
|
|
145
|
+
</span>
|
|
146
|
+
<span v-if="time !== undefined" class="wp-wl__time" data-test="working-line-time">{{ time }}</span>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<style scoped>
|
|
151
|
+
.wp-wl {
|
|
152
|
+
--wp-wl-t: 2.8s;
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 8px;
|
|
156
|
+
min-width: 0;
|
|
157
|
+
color: var(--wp-text-muted);
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
}
|
|
161
|
+
.wp-wl[data-tempo="mid"] { --wp-wl-t: 2.2s; }
|
|
162
|
+
.wp-wl[data-tempo="fast"] { --wp-wl-t: 1.5s; }
|
|
163
|
+
.wp-wl__logo { flex-shrink: 0; overflow: visible; }
|
|
164
|
+
.wp-wl__words {
|
|
165
|
+
position: relative;
|
|
166
|
+
display: inline-flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
min-width: 0;
|
|
169
|
+
max-width: 100%;
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
/* Narrows once the old word has mostly faded; widens at once. */
|
|
172
|
+
transition: width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) 0.3s;
|
|
173
|
+
}
|
|
174
|
+
.wp-wl__words[data-grow="true"] { transition: none; }
|
|
175
|
+
.wp-wl__word {
|
|
176
|
+
display: inline-block;
|
|
177
|
+
min-width: 0;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
text-overflow: ellipsis;
|
|
180
|
+
font-weight: 500;
|
|
181
|
+
/* A light band sweeps over the gradient text, like Claude's own line.
|
|
182
|
+
The band is a fixed white, like the logo's brand colours. */
|
|
183
|
+
background:
|
|
184
|
+
linear-gradient(100deg, transparent 35%, rgba(255, 255, 255, 0.75) 50%, transparent 65%) no-repeat,
|
|
185
|
+
linear-gradient(90deg, var(--wp-wl-big), var(--wp-wl-a) 45%, var(--wp-wl-b));
|
|
186
|
+
background-size: 250% 100%, 100% 100%;
|
|
187
|
+
background-position: 150% 0, 0 0;
|
|
188
|
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
189
|
+
animation: wp-wl-shimmer 2.6s linear infinite;
|
|
190
|
+
}
|
|
191
|
+
@keyframes wp-wl-shimmer {
|
|
192
|
+
0% { background-position: 150% 0, 0 0; }
|
|
193
|
+
70%, 100% { background-position: -50% 0, 0 0; }
|
|
194
|
+
}
|
|
195
|
+
/* Sized like the word, never seen: what the box grows or shrinks to. */
|
|
196
|
+
.wp-wl__measure {
|
|
197
|
+
position: absolute; left: 0; top: 0;
|
|
198
|
+
visibility: hidden; pointer-events: none; animation: none;
|
|
199
|
+
max-width: none; overflow: visible;
|
|
200
|
+
}
|
|
201
|
+
.wp-wl__time { flex-shrink: 0; font-variant-numeric: tabular-nums; color: var(--wp-text-dim); }
|
|
202
|
+
.wp-wl[data-inline="true"] { font-size: 12px; }
|
|
203
|
+
.wp-wl[data-inline="true"] .wp-wl__words { max-width: 240px; }
|
|
204
|
+
/* Crossfade in place: the old word fades out on top of the new one. The
|
|
205
|
+
* fade and blur run through registered custom properties (see the marks
|
|
206
|
+
* below for why): an `opacity` / `filter` transition lifts the word onto a
|
|
207
|
+
* compositor layer, which re-rasterises the text and snaps it to whole
|
|
208
|
+
* pixels as the fade begins. The leaving word keeps its exact spot (top 0,
|
|
209
|
+
* the box is one word tall), with no -50% translate to land between pixels. */
|
|
210
|
+
.wp-wl-word-enter-active, .wp-wl-word-leave-active {
|
|
211
|
+
opacity: var(--wp-wl-wo);
|
|
212
|
+
filter: blur(var(--wp-wl-wb));
|
|
213
|
+
transition: --wp-wl-wo 0.45s ease, --wp-wl-wb 0.45s ease;
|
|
214
|
+
}
|
|
215
|
+
.wp-wl-word-leave-active { position: absolute; left: 0; top: 0; }
|
|
216
|
+
.wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wo: 0; --wp-wl-wb: 2px; }
|
|
217
|
+
@media (prefers-reduced-motion: reduce) {
|
|
218
|
+
.wp-wl__word { animation: none; }
|
|
219
|
+
.wp-wl__words { transition: none; }
|
|
220
|
+
.wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wb: 0px; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* Every mark animates registered custom properties, never `transform` or
|
|
224
|
+
* `opacity` themselves. A `transform` / `opacity` animation goes to the
|
|
225
|
+
* compositor, which lifts each path onto its own layer as it starts:
|
|
226
|
+
* rasterised once at a fixed scale and snapped to whole pixels, so the sharp
|
|
227
|
+
* mark went soft and jumped half a pixel the moment it began to move. A
|
|
228
|
+
* custom property animates on the main thread and each frame repaints the
|
|
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; }
|
|
234
|
+
@property --wp-wl-o { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
235
|
+
@property --wp-wl-wo { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
236
|
+
@property --wp-wl-wb { syntax: "<length>"; inherits: false; initial-value: 0px; }
|
|
237
|
+
|
|
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
|
+
@media (prefers-reduced-motion: reduce) {
|
|
316
|
+
.wp-wl__logo * { animation: none !important; }
|
|
317
|
+
.wp-wl__logo { opacity: var(--wp-wl-o); animation: wp-wl-fade 2.4s ease-in-out infinite; }
|
|
318
|
+
@keyframes wp-wl-fade { 0%, 100% { --wp-wl-o: 1; } 50% { --wp-wl-o: 0.55; } }
|
|
319
|
+
}
|
|
320
|
+
</style>
|
package/src/WpIconButton.vue
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* WpIconButton: the 20 px square icon button inside a canvas row (module,
|
|
4
4
|
* injector, bundle and model rows, the Context toolbar). Rows are too tight
|
|
5
|
-
* for the 24 px `
|
|
5
|
+
* for the 24 px `Button size="sm"`, so this is the one smaller control on
|
|
6
6
|
* the canvas, still at the 20 px hit-target floor.
|
|
7
7
|
*
|
|
8
8
|
* <WpIconButton icon="pi-trash" label="Remove module" tone="danger" @click="remove" />
|
|
@@ -75,7 +75,8 @@ const emit = defineEmits<{ click: [evt: MouseEvent] }>();
|
|
|
75
75
|
.wp-icon-btn[data-tone="accent"] { --tone: var(--wp-accent); }
|
|
76
76
|
.wp-icon-btn[data-tone="warn"] { --tone: var(--wp-warn); }
|
|
77
77
|
.wp-icon-btn[data-tone="danger"] { --tone: var(--wp-danger); }
|
|
78
|
-
|
|
78
|
+
/* Even icon in the 18px inside of the box: an odd size sits half a pixel off. */
|
|
79
|
+
.wp-icon-btn .pi { font-size: 12px; }
|
|
79
80
|
.wp-icon-btn__glyph { font: 600 11px/1 var(--wp-font-mono); }
|
|
80
81
|
.wp-icon-btn:hover:not(:disabled) { background: var(--wp-bg-4); color: var(--tone); }
|
|
81
82
|
.wp-icon-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
package/src/WpImageZoom.vue
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* <WpImageZoom v-model:zoom="zoom" :src="url" alt="Frame 3" />
|
|
26
26
|
*/
|
|
27
27
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
28
|
-
import
|
|
28
|
+
import Button from "./Button.vue";
|
|
29
29
|
import {
|
|
30
30
|
centreOf, clampView, fitScale, percent, stepScale, viewAround, visibleRect, zoomAt, IDLE_MS, type Size, type View,
|
|
31
31
|
} from "./image-zoom";
|
|
@@ -298,13 +298,13 @@ const at = (s: number): boolean => !isFit.value && Math.abs(view.value.scale - s
|
|
|
298
298
|
@focusout="chromeLeave"
|
|
299
299
|
>
|
|
300
300
|
<slot name="bar-start" />
|
|
301
|
-
<
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
<
|
|
305
|
-
<
|
|
301
|
+
<Button variant="ghost" :size="size" icon="pi-minus" aria-label="Zoom out" kbd="−" data-test="imgzoom-out" @click="zoomOut" />
|
|
302
|
+
<Button :variant="isFit ? 'secondary' : 'ghost'" :size="size" kbd="0" hint="Show the whole picture" :aria-pressed="isFit" data-test="imgzoom-fit" @click="setZoom(null)">Fit</Button>
|
|
303
|
+
<Button :variant="at(1) ? 'secondary' : 'ghost'" :size="size" kbd="1" hint="Real size: one image pixel per screen pixel" :aria-pressed="at(1)" data-test="imgzoom-100" @click="setZoom(1)">100%</Button>
|
|
304
|
+
<Button :variant="at(2) ? 'secondary' : 'ghost'" :size="size" kbd="2" hint="Twice the real size" :aria-pressed="at(2)" data-test="imgzoom-200" @click="setZoom(2)">200%</Button>
|
|
305
|
+
<Button variant="ghost" :size="size" icon="pi-plus" aria-label="Zoom in" kbd="+" data-test="imgzoom-in" @click="zoomIn" />
|
|
306
306
|
<span class="wp-imgzoom__level" aria-live="polite" data-test="imgzoom-level">{{ level }}</span>
|
|
307
|
-
<
|
|
307
|
+
<Button
|
|
308
308
|
v-if="canFull"
|
|
309
309
|
variant="ghost"
|
|
310
310
|
:size="size"
|
package/src/WpSegmented.vue
CHANGED
|
@@ -168,7 +168,9 @@ function onKeydown(ev: KeyboardEvent): void {
|
|
|
168
168
|
.wp-segmented__opt[data-active="true"] .pi { color: var(--wp-accent-text); }
|
|
169
169
|
.wp-segmented__opt:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
170
170
|
.wp-segmented__opt:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
171
|
-
|
|
171
|
+
/* Even sizes: an icon-only option is a square, and an odd icon sits half a pixel off in it. */
|
|
172
|
+
.wp-segmented__opt .pi { font-size: 12px; }
|
|
173
|
+
.wp-segmented[data-size="lg"] .wp-segmented__opt .pi { font-size: 14px; }
|
|
172
174
|
@media (prefers-reduced-motion: reduce) {
|
|
173
175
|
.wp-segmented__opt { transition: none; }
|
|
174
176
|
}
|
package/src/WpSwitch.vue
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* The on/off switch for canvas widgets and the manager (
|
|
3
|
+
* The one on/off switch, for canvas widgets and the manager (size="md" there).
|
|
4
4
|
*
|
|
5
5
|
* <WpSwitch v-model="held" label="Hold across run" /> track
|
|
6
6
|
* <WpSwitch v-model="held" variant="chip" icon="pi-link" label="Hold across run" />
|
package/src/WpTabs.vue
CHANGED
|
@@ -165,7 +165,7 @@ function onKeydown(ev: KeyboardEvent): void {
|
|
|
165
165
|
.wp-tabset__tab[data-active="true"] { color: var(--wp-text); border-bottom-color: var(--wp-accent-400); }
|
|
166
166
|
.wp-tabset__tab:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--wp-border-focus); border-radius: var(--wp-radius-sm); }
|
|
167
167
|
.wp-tabset__tab:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
168
|
-
.wp-tabset__tab .pi { font-size:
|
|
168
|
+
.wp-tabset__tab .pi { font-size: 12px; }
|
|
169
169
|
.wp-tabset__count {
|
|
170
170
|
min-width: 16px;
|
|
171
171
|
padding: 1px 5px;
|
package/src/_modal-motion.css
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the dialog grows into place.
|
|
7
7
|
*
|
|
8
8
|
* Selector for the panel is `> *` (immediate child of the overlay
|
|
9
|
-
* element). Works for ModalShell's `.wp-modal-wrapper
|
|
9
|
+
* element). Works for ModalShell's `.wp-modal-wrapper` (Modal, ConfirmModal), the old
|
|
10
10
|
* `.wp-confirm`, InjectorBindingModal's `.ibm`, and any future modal
|
|
11
11
|
* that follows the overlay-with-one-child layout.
|
|
12
12
|
*
|
package/src/button.css
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
/* Button — the one .wp-btn stylesheet
|
|
2
|
-
*
|
|
3
|
-
* the
|
|
4
|
-
* standard rule 8), which pulls it in scoped. It reads only the
|
|
1
|
+
/* Button — the one .wp-btn stylesheet. Button.vue pulls it in scoped (so it
|
|
2
|
+
* works on the canvas), and the manager and the community site also load it
|
|
3
|
+
* globally for the few plain elements styled as buttons (a router-link). It reads only the
|
|
5
4
|
* SHARED_TOKENS names, so it wears the manager colours on a page and the
|
|
6
5
|
* canvas colours in a node.
|
|
7
6
|
*
|
|
8
7
|
* Unlayered on purpose: the manager loads it beside tokens.css, where an
|
|
9
8
|
* unlayered reset would otherwise beat it. The canvas never loads it
|
|
10
|
-
* globally:
|
|
9
|
+
* globally: Button pulls in a scoped copy, so its classes never meet
|
|
11
10
|
* another extension's `.wp-btn`.
|
|
12
11
|
*
|
|
13
12
|
* Heights follow the control scale: sm 24 / md 32 / lg 40.
|
|
@@ -21,6 +20,8 @@
|
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
.wp-btn {
|
|
23
|
+
/* A link drawn as a button (<a class="wp-btn">, Button as="a") loses the browser's underline; the link variant adds its own on hover. */
|
|
24
|
+
text-decoration: none;
|
|
24
25
|
height: var(--wp-control-h-md);
|
|
25
26
|
padding: 0 12px;
|
|
26
27
|
border-radius: min(7px, var(--wp-radius-cap, 999px));
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
outline: none;
|
|
47
48
|
box-shadow: var(--wp-focus-ring);
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
/* Even icon sizes in the even control heights: an odd one sits half a pixel off. */
|
|
51
|
+
.wp-btn .pi, .wp-btn svg { font-size: 14px; }
|
|
50
52
|
/* The label box (not on a link, which has no fixed height) is trimmed to
|
|
51
53
|
* the cap height where the browser can, so the
|
|
52
54
|
* flex centring puts the capitals, not the ascender-to-descender box, on the
|
package/src/floating.ts
CHANGED
|
@@ -53,6 +53,11 @@ function room(side: Side, a: Rect, view: Size, gap: number, margin: number): num
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Space on one side of the anchor for a panel, after the gap and margin. */
|
|
57
|
+
export function roomOn(side: Side, anchor: Rect, view: Size, opts: Pick<PlaceOptions, "gap" | "margin"> = {}): number {
|
|
58
|
+
return room(side, anchor, view, opts.gap ?? 6, opts.margin ?? 8);
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
function clamp(v: number, min: number, max: number): number {
|
|
57
62
|
return max < min ? min : Math.min(Math.max(v, min), max);
|
|
58
63
|
}
|
package/src/highlight.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax colours for CodeBlock: a small lexer per language, no dependency.
|
|
3
|
+
* It splits code into tokens of a few kinds; CodeBlock draws each kind in a
|
|
4
|
+
* shared colour token, so a block wears the colours of the side it is on.
|
|
5
|
+
*
|
|
6
|
+
* highlight('{"a": 1}', "json") → [{ kind: "punct", text: "{" }, { kind: "key", text: '"a"' }, …]
|
|
7
|
+
*
|
|
8
|
+
* Languages: json, ts (also js), html (also vue, xml), css, sh (also bash,
|
|
9
|
+
* shell, Modelfile), prompt (the Wildcard Pipeline template grammar: `$var`,
|
|
10
|
+
* `@{ref}`, `{a|b}`, `{2$$a|b}`, `$$` / `@@` escapes, `(word:1.2)` weights). Anything
|
|
11
|
+
* else comes back as one plain token. Every character of the input is in
|
|
12
|
+
* exactly one token, in order, so joining the tokens gives the input back.
|
|
13
|
+
*/
|
|
14
|
+
import { tokenizeRich } from "./rich-tokenize";
|
|
15
|
+
|
|
16
|
+
export type HighlightKind =
|
|
17
|
+
| "plain" | "keyword" | "string" | "number" | "comment" | "punct"
|
|
18
|
+
| "key" | "tag" | "attr" | "fn" | "var" | "ref" | "brace" | "escape";
|
|
19
|
+
|
|
20
|
+
export interface HighlightToken {
|
|
21
|
+
kind: HighlightKind;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type HighlightLanguage = "json" | "ts" | "html" | "css" | "sh" | "prompt";
|
|
26
|
+
|
|
27
|
+
const ALIASES: Record<string, HighlightLanguage> = {
|
|
28
|
+
json: "json",
|
|
29
|
+
ts: "ts", typescript: "ts", js: "ts", javascript: "ts", tsx: "ts", jsx: "ts",
|
|
30
|
+
html: "html", vue: "html", xml: "html", svg: "html",
|
|
31
|
+
css: "css",
|
|
32
|
+
sh: "sh", bash: "sh", shell: "sh", zsh: "sh", console: "sh", modelfile: "sh", dockerfile: "sh",
|
|
33
|
+
prompt: "prompt", template: "prompt", wildcard: "prompt",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The grammar for a language name ("JSON", "Vue", "bash" …), or null. */
|
|
37
|
+
export function highlightLanguage(name: string | null | undefined): HighlightLanguage | null {
|
|
38
|
+
if (!name) return null;
|
|
39
|
+
return ALIASES[name.trim().toLowerCase()] ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Rule = [HighlightKind, RegExp];
|
|
43
|
+
|
|
44
|
+
const TS_KEYWORDS = /^(?:import|from|export|default|const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|interface|type|enum|as|in|of|typeof|instanceof|async|await|try|catch|finally|throw|void|this|null|undefined|true|false|readonly|public|private|protected|static|satisfies|keyof)$/;
|
|
45
|
+
|
|
46
|
+
const RULES: Record<Exclude<HighlightLanguage, "prompt" | "html">, Rule[]> = {
|
|
47
|
+
json: [
|
|
48
|
+
["key", /"(?:[^"\\\n]|\\.)*"(?=\s*:)/y],
|
|
49
|
+
["string", /"(?:[^"\\\n]|\\.)*"/y],
|
|
50
|
+
["number", /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y],
|
|
51
|
+
["keyword", /\b(?:true|false|null)\b/y],
|
|
52
|
+
["punct", /[{}[\],:]/y],
|
|
53
|
+
],
|
|
54
|
+
ts: [
|
|
55
|
+
["comment", /\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)/y],
|
|
56
|
+
["string", /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/y],
|
|
57
|
+
["number", /\b\d+(?:\.\d+)?\b/y],
|
|
58
|
+
["fn", /[A-Za-z_$][\w$]*(?=\s*\()/y],
|
|
59
|
+
["plain", /[A-Za-z_$][\w$]*/y],
|
|
60
|
+
["punct", /[{}()[\];,.<>=!&|?:+\-*/%]+/y],
|
|
61
|
+
],
|
|
62
|
+
css: [
|
|
63
|
+
["comment", /\/\*[\s\S]*?(?:\*\/|$)/y],
|
|
64
|
+
["string", /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/y],
|
|
65
|
+
["key", /[\w-]+(?=\s*:[^:{]*[;}\n])/y],
|
|
66
|
+
["var", /--[\w-]+/y],
|
|
67
|
+
["number", /#[\da-fA-F]{3,8}\b|-?\d*\.?\d+(?:px|em|rem|%|vh|vw|s|ms|deg|fr)?\b/y],
|
|
68
|
+
["fn", /[\w-]+(?=\()/y],
|
|
69
|
+
["tag", /[.#]?[\w-]+(?::{1,2}[\w-]+)*(?=[^{};]*\{)/y],
|
|
70
|
+
["punct", /[{}();:,>+~*]/y],
|
|
71
|
+
],
|
|
72
|
+
sh: [
|
|
73
|
+
["comment", /#[^\n]*/y],
|
|
74
|
+
["string", /"(?:[^"\\\n]|\\.)*"|'[^'\n]*'/y],
|
|
75
|
+
["var", /\$\{?[\w]+\}?/y],
|
|
76
|
+
["attr", /(?<=\s)--?[\w-]+/y],
|
|
77
|
+
["fn", /(?<=^|\n|[|;&]\s*)[A-Za-z_][\w.-]*/y],
|
|
78
|
+
["punct", /[|&;<>()]+/y],
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function lex(code: string, rules: Rule[]): HighlightToken[] {
|
|
83
|
+
const out: HighlightToken[] = [];
|
|
84
|
+
let plain = "";
|
|
85
|
+
let i = 0;
|
|
86
|
+
const flush = (): void => { if (plain) { out.push({ kind: "plain", text: plain }); plain = ""; } };
|
|
87
|
+
outer: while (i < code.length) {
|
|
88
|
+
for (const [kind, re] of rules) {
|
|
89
|
+
re.lastIndex = i;
|
|
90
|
+
const m = re.exec(code);
|
|
91
|
+
if (m && m[0].length > 0) {
|
|
92
|
+
let k = kind;
|
|
93
|
+
if (rules === RULES.ts && kind === "plain" && TS_KEYWORDS.test(m[0])) k = "keyword";
|
|
94
|
+
if (k === "plain") plain += m[0];
|
|
95
|
+
else { flush(); out.push({ kind: k, text: m[0] }); }
|
|
96
|
+
i += m[0].length;
|
|
97
|
+
continue outer;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
plain += code[i];
|
|
101
|
+
i += 1;
|
|
102
|
+
}
|
|
103
|
+
flush();
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** HTML and Vue templates: tags, attributes, their values, `{{ }}` as TS. */
|
|
108
|
+
function lexHtml(code: string): HighlightToken[] {
|
|
109
|
+
const out: HighlightToken[] = [];
|
|
110
|
+
const re = /<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z][\w.:-]*|\/?>|\{\{[\s\S]*?(?:\}\}|$)/g;
|
|
111
|
+
let at = 0;
|
|
112
|
+
let inTag = false;
|
|
113
|
+
const text = (s: string): void => {
|
|
114
|
+
if (!s) return;
|
|
115
|
+
if (!inTag) { out.push({ kind: "plain", text: s }); return; }
|
|
116
|
+
// Attributes inside a tag: name, =, "value".
|
|
117
|
+
const attr = /("[^"]*"|'[^']*')|([:@#]?[\w.:@#-]+)|(=)|(\s+)|([^\s"'=]+)/g;
|
|
118
|
+
for (const m of s.matchAll(attr)) {
|
|
119
|
+
if (m[1]) out.push({ kind: "string", text: m[1] });
|
|
120
|
+
else if (m[2]) out.push({ kind: "attr", text: m[2] });
|
|
121
|
+
else if (m[3]) out.push({ kind: "punct", text: m[3] });
|
|
122
|
+
else out.push({ kind: "plain", text: m[0] });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
for (const m of code.matchAll(re)) {
|
|
126
|
+
const idx = m.index ?? 0;
|
|
127
|
+
text(code.slice(at, idx));
|
|
128
|
+
const t = m[0];
|
|
129
|
+
if (t.startsWith("<!--")) out.push({ kind: "comment", text: t });
|
|
130
|
+
else if (t.startsWith("{{") && !inTag) {
|
|
131
|
+
out.push({ kind: "punct", text: "{{" });
|
|
132
|
+
const inner = t.slice(2, t.endsWith("}}") ? -2 : undefined);
|
|
133
|
+
out.push(...lex(inner, RULES.ts));
|
|
134
|
+
if (t.endsWith("}}")) out.push({ kind: "punct", text: "}}" });
|
|
135
|
+
} else if (t.startsWith("<")) {
|
|
136
|
+
out.push({ kind: "punct", text: t.startsWith("</") ? "</" : "<" });
|
|
137
|
+
out.push({ kind: "tag", text: t.replace(/^<\/?/, "") });
|
|
138
|
+
inTag = true;
|
|
139
|
+
} else if (t.endsWith(">")) {
|
|
140
|
+
out.push({ kind: "punct", text: t });
|
|
141
|
+
inTag = false;
|
|
142
|
+
} else {
|
|
143
|
+
text(t);
|
|
144
|
+
}
|
|
145
|
+
at = idx + t.length;
|
|
146
|
+
}
|
|
147
|
+
text(code.slice(at));
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The template grammar, through the kit's own tokenizer; `(word:1.2)` weights too. */
|
|
152
|
+
function lexPrompt(code: string): HighlightToken[] {
|
|
153
|
+
const out: HighlightToken[] = [];
|
|
154
|
+
const plain = (text: string): void => {
|
|
155
|
+
// Plain text: pick out `:1.2)` weights.
|
|
156
|
+
let at = 0;
|
|
157
|
+
for (const m of text.matchAll(/:(-?\d+(?:\.\d+)?)(?=\))/g)) {
|
|
158
|
+
const idx = (m.index ?? 0) + 1;
|
|
159
|
+
if (idx > at) out.push({ kind: "plain", text: text.slice(at, idx) });
|
|
160
|
+
out.push({ kind: "number", text: m[1] ?? "" });
|
|
161
|
+
at = idx + (m[1]?.length ?? 0);
|
|
162
|
+
}
|
|
163
|
+
if (at < text.length) out.push({ kind: "plain", text: text.slice(at) });
|
|
164
|
+
};
|
|
165
|
+
let at = 0;
|
|
166
|
+
for (const t of tokenizeRich(code)) {
|
|
167
|
+
// The tokenizer may leave gaps (it reports what it recognises); keep them as text.
|
|
168
|
+
if (t.start > at) plain(code.slice(at, t.start));
|
|
169
|
+
const text = code.slice(t.start, t.end);
|
|
170
|
+
if (t.kind === "var") out.push({ kind: "var", text });
|
|
171
|
+
else if (t.kind === "ref") out.push({ kind: "ref", text });
|
|
172
|
+
else if (t.kind === "dp-brace" || t.kind === "dp-multi") out.push({ kind: "brace", text });
|
|
173
|
+
else if (t.kind === "escape") out.push({ kind: "escape", text });
|
|
174
|
+
else plain(text);
|
|
175
|
+
at = Math.max(at, t.end);
|
|
176
|
+
}
|
|
177
|
+
if (at < code.length) plain(code.slice(at));
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function highlight(code: string, language: HighlightLanguage | null): HighlightToken[] {
|
|
182
|
+
if (!language || !code) return [{ kind: "plain", text: code }];
|
|
183
|
+
if (language === "html") return lexHtml(code);
|
|
184
|
+
if (language === "prompt") return lexPrompt(code);
|
|
185
|
+
return lex(code, RULES[language]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Tokens cut at line breaks, for numbered lines. */
|
|
189
|
+
export function highlightLines(tokens: HighlightToken[]): HighlightToken[][] {
|
|
190
|
+
const lines: HighlightToken[][] = [[]];
|
|
191
|
+
for (const t of tokens) {
|
|
192
|
+
const parts = t.text.split("\n");
|
|
193
|
+
parts.forEach((p, i) => {
|
|
194
|
+
if (i > 0) lines.push([]);
|
|
195
|
+
if (p) lines[lines.length - 1]?.push({ kind: t.kind, text: p });
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return lines;
|
|
199
|
+
}
|