astro-dev-edit 0.11.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/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import type { ClassifyResult, SourceLoc } from "../shared/protocol.ts";
|
|
2
|
+
import { classifyCached, peekClassification } from "./classify-cache.ts";
|
|
3
|
+
import { buildRulesCard, rulesForToken } from "./css-inspect.ts";
|
|
4
|
+
import { icon, setIcon } from "./icons.ts";
|
|
5
|
+
import { nearestSource, sourceFor } from "./source-map.ts";
|
|
6
|
+
import { isOwnUi, mount } from './shadow.ts';
|
|
7
|
+
import {
|
|
8
|
+
COLOR,
|
|
9
|
+
basename,
|
|
10
|
+
chromeInset,
|
|
11
|
+
hexToRgba,
|
|
12
|
+
outlineRect,
|
|
13
|
+
pillButton,
|
|
14
|
+
setPillLabel,
|
|
15
|
+
styled,
|
|
16
|
+
} from "./ui.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hover behaviour: the outline that tracks the hovered source-mapped element
|
|
20
|
+
* and the interactive tooltip pill (file:loc · verdict, plus "open ↗" and
|
|
21
|
+
* "copy ⧉" buttons). Clicking the file:loc label opens the in-browser source
|
|
22
|
+
* peek; "open ↗" jumps to the editor; "copy ⧉" puts the element's context
|
|
23
|
+
* (HTML, CSS, source) on the clipboard. Hover state is self-contained
|
|
24
|
+
* here — it never interacts with the editing slot in state.ts.
|
|
25
|
+
*
|
|
26
|
+
* The pill never guesses. It appears instantly in a neutral "checking" state
|
|
27
|
+
* (no editability claim), and once the pointer has dwelled on one element the
|
|
28
|
+
* server's AST-truth /classify upgrades it to the verdict a click would get:
|
|
29
|
+
* editable, image, or dynamic. Verdicts come through classify-cache.ts, so an
|
|
30
|
+
* element costs one round-trip per file version, not one per mouse pass, and
|
|
31
|
+
* a re-hover of a known element shows its verdict immediately. (spec §7.3)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** What the pill claims about the highlighted element. */
|
|
35
|
+
interface Verdict {
|
|
36
|
+
word: string;
|
|
37
|
+
color: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Neutral pre-verification state — no editability claim yet. */
|
|
41
|
+
const CHECKING: Verdict = { word: "loading…", color: COLOR.mutedFg };
|
|
42
|
+
|
|
43
|
+
/** Collapse the server's classification onto the pill's word + color,
|
|
44
|
+
* mirroring how router.ts will route the eventual click. (spec §16.1) */
|
|
45
|
+
function verdictFor(result: ClassifyResult): Verdict {
|
|
46
|
+
if (result.kind === "text") return { word: "editable", color: COLOR.brandText };
|
|
47
|
+
if (result.kind === "image") {
|
|
48
|
+
const attrs = result.attrs ?? { src: "dynamic", alt: "dynamic" };
|
|
49
|
+
// Same rule as the click router: when neither src nor alt is patchable
|
|
50
|
+
// the click ends in a refusal notice, so the pill says dynamic.
|
|
51
|
+
if (attrs.src !== "static" && attrs.alt === "dynamic") {
|
|
52
|
+
return { word: "dynamic", color: COLOR.warning };
|
|
53
|
+
}
|
|
54
|
+
return { word: "image", color: COLOR.info };
|
|
55
|
+
}
|
|
56
|
+
// empty / dynamic / ambiguous / unresolved all route to the refusal notice.
|
|
57
|
+
return { word: "dynamic", color: COLOR.warning };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Elements (appended to the body by the composition root at boot) --------
|
|
61
|
+
|
|
62
|
+
const outline = styled("div", "atx-outline", undefined, "atx-outline");
|
|
63
|
+
|
|
64
|
+
// The pill is interactive: hovering it keeps it open, and its "open source"
|
|
65
|
+
// button jumps to the element's source in the editor. (#3)
|
|
66
|
+
const tooltip = styled("div", "atx-tooltip", undefined, "atx-tooltip");
|
|
67
|
+
|
|
68
|
+
// Row 1: the file:loc · verdict label and the "open ↗" editor jump, kept on one
|
|
69
|
+
// line regardless of the chips row below.
|
|
70
|
+
const tooltipRow = styled("span", "atx-tooltip-row");
|
|
71
|
+
|
|
72
|
+
// The label is clickable too — the whole file:loc line opens the in-browser
|
|
73
|
+
// source peek (the "open ↗" button next to it is the editor jump). (#3)
|
|
74
|
+
const tooltipLabel = styled("span", "atx-tooltip-label");
|
|
75
|
+
// The label is two spans so the verdict can sit in a fixed-width slot — see
|
|
76
|
+
// .atx-tooltip-verdict in styles.ts for why that width exists.
|
|
77
|
+
const tooltipLoc = styled("span", "atx-tooltip-loc");
|
|
78
|
+
const tooltipVerdict = styled("span", "atx-tooltip-verdict");
|
|
79
|
+
tooltipLabel.append(tooltipLoc, tooltipVerdict);
|
|
80
|
+
tooltipLabel.title = "Peek at the source code";
|
|
81
|
+
const tooltipOpen = pillButton(
|
|
82
|
+
"atx-tooltip-open",
|
|
83
|
+
"open",
|
|
84
|
+
"Open this location in your editor",
|
|
85
|
+
{},
|
|
86
|
+
icon("external", 12),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// The context copy: everything we know about this element as one markdown
|
|
90
|
+
// block, for pasting into an AI assistant. Its label swaps through
|
|
91
|
+
// copying…/copied, so it holds a fixed width — the pill must not resize
|
|
92
|
+
// mid-interaction (the verdict slot next to it exists for the same reason).
|
|
93
|
+
//
|
|
94
|
+
// The width is the widest of the three labels, which is the idle one: 12
|
|
95
|
+
// characters of 11px/600 UI text (~68px) plus the pill's own 12px icon, 4px
|
|
96
|
+
// gap and 2×7px padding, rounded up for a font that measures wider.
|
|
97
|
+
const COPY_IDLE = "copy context";
|
|
98
|
+
const copyIcon = icon("copy", 12);
|
|
99
|
+
const tooltipCopy = pillButton(
|
|
100
|
+
"atx-tooltip-copy",
|
|
101
|
+
COPY_IDLE,
|
|
102
|
+
"Copy this element's source, HTML and CSS as context for an AI assistant",
|
|
103
|
+
{ minWidth: "108px" },
|
|
104
|
+
copyIcon,
|
|
105
|
+
);
|
|
106
|
+
tooltipRow.append(tooltipLabel, tooltipOpen, tooltipCopy);
|
|
107
|
+
|
|
108
|
+
let copyResetTimer: number | null = null;
|
|
109
|
+
|
|
110
|
+
/** Back to the idle label, cancelling any pending flash. Runs on every
|
|
111
|
+
* highlight change too, so a "copied" never carries onto another element. */
|
|
112
|
+
function resetCopy(): void {
|
|
113
|
+
if (copyResetTimer !== null) {
|
|
114
|
+
clearTimeout(copyResetTimer);
|
|
115
|
+
copyResetTimer = null;
|
|
116
|
+
}
|
|
117
|
+
tooltipCopy.disabled = false;
|
|
118
|
+
setPillLabel(tooltipCopy, COPY_IDLE);
|
|
119
|
+
setIcon(copyIcon, "copy", 12);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function flashCopy(label: string): void {
|
|
123
|
+
setPillLabel(tooltipCopy, label);
|
|
124
|
+
setIcon(copyIcon, "check", 12);
|
|
125
|
+
copyResetTimer = window.setTimeout(resetCopy, 1200);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Row 2: one chip per class + the id. Hovering a chip pops a rules card (see
|
|
129
|
+
// css-inspect.ts). Wraps within a cap; hidden when the element has neither.
|
|
130
|
+
const tooltipChips = styled("div", "atx-tooltip-chips");
|
|
131
|
+
|
|
132
|
+
tooltip.append(tooltipRow, tooltipChips);
|
|
133
|
+
|
|
134
|
+
// --- Hover state -------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
// Set once by initHover. Notifies an observer (the element-tree panel) whenever
|
|
137
|
+
// the highlighted page element changes, so it can mirror the highlight onto its
|
|
138
|
+
// matching row (page → tree sync). Lives at module scope because clearHighlight
|
|
139
|
+
// — which also changes the target — is exported and runs outside initHover.
|
|
140
|
+
let onTargetCb: ((el: HTMLElement | null) => void) | null = null;
|
|
141
|
+
|
|
142
|
+
let highlighted: HTMLElement | null = null;
|
|
143
|
+
// Source of the currently-highlighted element, so the pill's "open" button
|
|
144
|
+
// knows what to open.
|
|
145
|
+
let highlightedSrc: SourceLoc | null = null;
|
|
146
|
+
// Grace timer: when the mouse leaves an element we wait briefly before hiding,
|
|
147
|
+
// so the user can travel up to the pill and click it without it vanishing. (#3)
|
|
148
|
+
let hideTimer: number | null = null;
|
|
149
|
+
// Switch timer: travelling from an element up to its pill often crosses a
|
|
150
|
+
// *different* annotated element (the parent, or a sibling the pill overlaps).
|
|
151
|
+
// Retargeting instantly would yank the pill away mid-travel, so a switch to a
|
|
152
|
+
// new element only lands after a short dwell; reaching the pill (or wandering
|
|
153
|
+
// back) cancels it. The first highlight is never delayed.
|
|
154
|
+
const SWITCH_DELAY = 150;
|
|
155
|
+
let switchTimer: number | null = null;
|
|
156
|
+
let switchTarget: HTMLElement | null = null;
|
|
157
|
+
// Verify dwell: the neutral pill upgrades to a server verdict only after the
|
|
158
|
+
// pointer has rested on one element this long — sweeping the mouse across the
|
|
159
|
+
// page must not fire a /classify per element crossed.
|
|
160
|
+
const VERIFY_DELAY = 500;
|
|
161
|
+
let verifyTimer: number | null = null;
|
|
162
|
+
// Monotonic token, bumped whenever the highlight changes or clears, so a
|
|
163
|
+
// /classify response that lands late can tell its element is no longer
|
|
164
|
+
// highlighted and must not repaint the pill.
|
|
165
|
+
let highlightSeq = 0;
|
|
166
|
+
|
|
167
|
+
function cancelHide(): void {
|
|
168
|
+
if (hideTimer !== null) {
|
|
169
|
+
clearTimeout(hideTimer);
|
|
170
|
+
hideTimer = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function scheduleHide(): void {
|
|
175
|
+
cancelHide();
|
|
176
|
+
hideTimer = window.setTimeout(clearHighlight, 220);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function cancelSwitch(): void {
|
|
180
|
+
if (switchTimer !== null) {
|
|
181
|
+
clearTimeout(switchTimer);
|
|
182
|
+
switchTimer = null;
|
|
183
|
+
}
|
|
184
|
+
switchTarget = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function cancelVerify(): void {
|
|
188
|
+
if (verifyTimer !== null) {
|
|
189
|
+
clearTimeout(verifyTimer);
|
|
190
|
+
verifyTimer = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The rules card popped from a chip. On-demand (like a toast), never in the boot
|
|
195
|
+
// append list: at most one exists, rebuilt per chip hover and removed on leave.
|
|
196
|
+
let rulesCard: HTMLElement | null = null;
|
|
197
|
+
let cardHideTimer: number | null = null;
|
|
198
|
+
|
|
199
|
+
function cancelCardHide(): void {
|
|
200
|
+
if (cardHideTimer !== null) {
|
|
201
|
+
clearTimeout(cardHideTimer);
|
|
202
|
+
cardHideTimer = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function removeCard(): void {
|
|
207
|
+
cancelCardHide();
|
|
208
|
+
rulesCard?.remove();
|
|
209
|
+
rulesCard = null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function scheduleCardHide(): void {
|
|
213
|
+
cancelCardHide();
|
|
214
|
+
cardHideTimer = window.setTimeout(removeCard, 200);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function clearHighlight(): void {
|
|
218
|
+
cancelHide();
|
|
219
|
+
cancelSwitch();
|
|
220
|
+
cancelVerify();
|
|
221
|
+
removeCard();
|
|
222
|
+
resetCopy();
|
|
223
|
+
highlightSeq++; // invalidate any /classify still in flight
|
|
224
|
+
const had = highlighted;
|
|
225
|
+
highlighted = null;
|
|
226
|
+
highlightedSrc = null;
|
|
227
|
+
outline.toggleAttribute("data-on", false);
|
|
228
|
+
tooltip.toggleAttribute("data-on", false);
|
|
229
|
+
if (had) onTargetCb?.(null);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Keep the pill open while hovered. (#3)
|
|
233
|
+
tooltip.addEventListener("mouseenter", cancelHide);
|
|
234
|
+
tooltip.addEventListener("mouseleave", scheduleHide);
|
|
235
|
+
|
|
236
|
+
export interface HoverDeps {
|
|
237
|
+
isEditMode(): boolean;
|
|
238
|
+
openSource(src: SourceLoc): void;
|
|
239
|
+
/** Open the in-browser source-peek panel for a loc. */
|
|
240
|
+
openPeek(src: SourceLoc): void;
|
|
241
|
+
/** Whether the CSS class/ID inspector is on — the chips row renders only then. */
|
|
242
|
+
cssInspector(): boolean;
|
|
243
|
+
/** Open a CSS rule's source file at (near) the rule in the editor. */
|
|
244
|
+
openRule(file: string, selector: string): void;
|
|
245
|
+
/** Gather the element's context and put it on the clipboard. Resolves true
|
|
246
|
+
* only when it actually landed there — a fallback panel or a failure
|
|
247
|
+
* resolves false, and the button returns to its idle label. */
|
|
248
|
+
copyContext(el: HTMLElement, src: SourceLoc): Promise<boolean>;
|
|
249
|
+
/** Notified whenever the highlighted element changes (element-tree sync). */
|
|
250
|
+
onTarget?(el: HTMLElement | null): void;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What initHover hands back to the composition root: the DOM nodes to append,
|
|
254
|
+
* and a programmatic `highlight` so the element-tree panel can drive the same
|
|
255
|
+
* outline + verdict pill from a row hover (tree → page sync). */
|
|
256
|
+
export interface HoverHandle {
|
|
257
|
+
elements: HTMLElement[];
|
|
258
|
+
highlight(el: HTMLElement): void;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Wire the hover listeners; returns the elements for the boot code to append
|
|
262
|
+
* once the server health check passes, plus a programmatic highlight. */
|
|
263
|
+
export function initHover(deps: HoverDeps): HoverHandle {
|
|
264
|
+
onTargetCb = deps.onTarget ?? null;
|
|
265
|
+
const onHighlighted = (open: (src: SourceLoc) => void) => (e: MouseEvent): void => {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
e.stopPropagation();
|
|
268
|
+
if (highlightedSrc) open(highlightedSrc);
|
|
269
|
+
};
|
|
270
|
+
tooltipOpen.addEventListener("click", onHighlighted(deps.openSource));
|
|
271
|
+
tooltipLabel.addEventListener("click", onHighlighted(deps.openPeek));
|
|
272
|
+
|
|
273
|
+
tooltipCopy.addEventListener("click", (e) => {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
e.stopPropagation();
|
|
276
|
+
const el = highlighted;
|
|
277
|
+
const src = highlightedSrc;
|
|
278
|
+
if (!el || !src) return;
|
|
279
|
+
// Gathering takes a /peek round-trip; hold the pill open across it. The
|
|
280
|
+
// element and loc are captured up front, so the copy still completes if the
|
|
281
|
+
// pointer wanders off and the highlight clears meanwhile.
|
|
282
|
+
cancelHide();
|
|
283
|
+
resetCopy();
|
|
284
|
+
tooltipCopy.disabled = true;
|
|
285
|
+
setPillLabel(tooltipCopy, "copying…");
|
|
286
|
+
setIcon(copyIcon, "spinner", 12);
|
|
287
|
+
void deps.copyContext(el, src).then((copied) => {
|
|
288
|
+
tooltipCopy.disabled = false;
|
|
289
|
+
if (copied) flashCopy("copied");
|
|
290
|
+
else resetCopy();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/** Sit the pill fully above the element, measured by its actual height (so the
|
|
295
|
+
* taller chips-row variant never overlaps the element). Only when there's no
|
|
296
|
+
* room above does it drop just below. The admin bar's strip counts as "no
|
|
297
|
+
* room" — a pill hidden under it is worse than one below the element. Must run
|
|
298
|
+
* after the pill's content is in place — including the chips — for
|
|
299
|
+
* offsetHeight to be right. */
|
|
300
|
+
function positionPill(rect: DOMRect): void {
|
|
301
|
+
const gap = 6;
|
|
302
|
+
const inset = chromeInset();
|
|
303
|
+
const minTop = inset.top + 4;
|
|
304
|
+
const maxBottom = window.innerHeight - inset.bottom - 4;
|
|
305
|
+
const above = rect.top - tooltip.offsetHeight - gap;
|
|
306
|
+
const below = rect.bottom + gap;
|
|
307
|
+
tooltip.style.left = `${Math.max(4, rect.left)}px`;
|
|
308
|
+
const fitsBelow = below + tooltip.offsetHeight <= maxBottom;
|
|
309
|
+
tooltip.style.top = `${above >= minTop ? above : fitsBelow ? below : minTop}px`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Paint the outline + pill for `el` with the given verdict. */
|
|
313
|
+
function render(el: HTMLElement, verdict: Verdict): void {
|
|
314
|
+
const rect = el.getBoundingClientRect();
|
|
315
|
+
|
|
316
|
+
outline.toggleAttribute("data-on", true);
|
|
317
|
+
Object.assign(outline.style, {
|
|
318
|
+
...outlineRect(rect),
|
|
319
|
+
// The verdict picks the colour, so these two cannot live in the sheet.
|
|
320
|
+
borderColor: verdict.color,
|
|
321
|
+
background: hexToRgba(verdict.color, 0.08),
|
|
322
|
+
} as Partial<CSSStyleDeclaration>);
|
|
323
|
+
|
|
324
|
+
const file = highlightedSrc?.file ?? "";
|
|
325
|
+
const loc = highlightedSrc?.loc || "?";
|
|
326
|
+
tooltipLoc.textContent = `${basename(file)}:${loc} · `;
|
|
327
|
+
tooltipVerdict.textContent = verdict.word;
|
|
328
|
+
// The verdict's colour is the outline's alone. Repeating it as an edge on
|
|
329
|
+
// the pill made two marks claim the same one thing, and cost the pill its
|
|
330
|
+
// symmetry — it is a label, the box around the element is the highlight.
|
|
331
|
+
tooltip.toggleAttribute("data-on", true);
|
|
332
|
+
positionPill(rect);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Ask the server (through the cache) and, if the pointer is still on the
|
|
336
|
+
* same element, upgrade the neutral pill to the real verdict. */
|
|
337
|
+
async function verify(el: HTMLElement, src: SourceLoc, seq: number): Promise<void> {
|
|
338
|
+
let result: ClassifyResult;
|
|
339
|
+
try {
|
|
340
|
+
result = await classifyCached({ file: src.file, loc: src.loc, tag: el.tagName.toLowerCase() });
|
|
341
|
+
} catch {
|
|
342
|
+
return; // hover is passive — stay neutral; the click path surfaces errors
|
|
343
|
+
}
|
|
344
|
+
// The mouse may have moved on (or the highlight cleared) mid-round-trip.
|
|
345
|
+
if (seq !== highlightSeq || highlighted !== el) return;
|
|
346
|
+
// Chips stay put on a verdict upgrade — only the loc/verdict line repaints.
|
|
347
|
+
render(el, verdictFor(result));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- Class/ID chips + rules card ------------------------------------------
|
|
351
|
+
|
|
352
|
+
/** Place the card below its chip, flipping above / clamping to stay on-screen. */
|
|
353
|
+
function positionCard(card: HTMLElement, anchor: HTMLElement): void {
|
|
354
|
+
const r = anchor.getBoundingClientRect();
|
|
355
|
+
const cw = card.offsetWidth;
|
|
356
|
+
const ch = card.offsetHeight;
|
|
357
|
+
let left = Math.min(r.left, window.innerWidth - 8 - cw);
|
|
358
|
+
left = Math.max(8, left);
|
|
359
|
+
let top = r.bottom + 6;
|
|
360
|
+
if (top + ch > window.innerHeight - 8) top = Math.max(8, r.top - 6 - ch);
|
|
361
|
+
card.style.left = `${left}px`;
|
|
362
|
+
card.style.top = `${top}px`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function showCard(anchor: HTMLElement, token: string, kind: "class" | "id", el: HTMLElement): void {
|
|
366
|
+
removeCard();
|
|
367
|
+
const selector = (kind === "class" ? "." : "#") + token;
|
|
368
|
+
const card = buildRulesCard(selector, rulesForToken(el, token, kind), deps.openRule);
|
|
369
|
+
card.addEventListener("mouseenter", () => {
|
|
370
|
+
cancelHide();
|
|
371
|
+
cancelCardHide();
|
|
372
|
+
});
|
|
373
|
+
card.addEventListener("mouseleave", () => {
|
|
374
|
+
scheduleCardHide();
|
|
375
|
+
scheduleHide();
|
|
376
|
+
});
|
|
377
|
+
mount(card);
|
|
378
|
+
positionCard(card, anchor);
|
|
379
|
+
rulesCard = card;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function makeChip(label: string, token: string, kind: "class" | "id", el: HTMLElement): HTMLElement {
|
|
383
|
+
const chip = styled("span", "atx-tooltip-chip");
|
|
384
|
+
chip.textContent = label;
|
|
385
|
+
chip.title = `Show CSS applied via ${label}`;
|
|
386
|
+
// The border on hover is CSS; these listeners are here for the card.
|
|
387
|
+
chip.addEventListener("mouseenter", () => {
|
|
388
|
+
cancelHide();
|
|
389
|
+
showCard(chip, token, kind, el);
|
|
390
|
+
});
|
|
391
|
+
chip.addEventListener("mouseleave", scheduleCardHide);
|
|
392
|
+
return chip;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Rebuild the chips row for `el` (classes, then id). Hidden when the
|
|
396
|
+
* inspector is off or the element has neither. Astro's scope class is noise. */
|
|
397
|
+
function renderChips(el: HTMLElement): void {
|
|
398
|
+
tooltipChips.replaceChildren();
|
|
399
|
+
if (!deps.cssInspector()) {
|
|
400
|
+
tooltipChips.toggleAttribute("data-on", false);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const chips: HTMLElement[] = [];
|
|
404
|
+
el.classList.forEach((c) => {
|
|
405
|
+
if (/^astro-[\w-]+$/.test(c)) return; // Astro scoping class, not authored
|
|
406
|
+
chips.push(makeChip(`.${c}`, c, "class", el));
|
|
407
|
+
});
|
|
408
|
+
if (el.id) chips.push(makeChip(`#${el.id}`, el.id, "id", el));
|
|
409
|
+
if (chips.length === 0) {
|
|
410
|
+
tooltipChips.toggleAttribute("data-on", false);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
tooltipChips.toggleAttribute("data-on", true);
|
|
414
|
+
tooltipChips.append(...chips);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function highlight(el: HTMLElement): void {
|
|
418
|
+
cancelSwitch();
|
|
419
|
+
cancelVerify();
|
|
420
|
+
removeCard(); // drop any card left over from the previous element
|
|
421
|
+
resetCopy(); // a "copied ✓" belongs to the element it was clicked on
|
|
422
|
+
if (!el.isConnected) return; // HMR may have replaced it during the dwell
|
|
423
|
+
highlighted = el;
|
|
424
|
+
onTargetCb?.(el); // mirror onto the element-tree row (page → tree)
|
|
425
|
+
const src = sourceFor(el) ?? null;
|
|
426
|
+
highlightedSrc = src;
|
|
427
|
+
const seq = ++highlightSeq;
|
|
428
|
+
|
|
429
|
+
// An already-verified element shows its verdict immediately; otherwise
|
|
430
|
+
// render the neutral state and verify once the pointer has dwelled.
|
|
431
|
+
const cached = src ? peekClassification({ file: src.file, loc: src.loc, tag: el.tagName.toLowerCase() }) : undefined;
|
|
432
|
+
render(el, cached ? verdictFor(cached) : CHECKING);
|
|
433
|
+
renderChips(el);
|
|
434
|
+
// The chips row changes the pill's height — re-place it so it still clears
|
|
435
|
+
// the element (render's placement used the pre-chips height).
|
|
436
|
+
positionPill(el.getBoundingClientRect());
|
|
437
|
+
if (!src || cached) return;
|
|
438
|
+
verifyTimer = window.setTimeout(() => {
|
|
439
|
+
verifyTimer = null;
|
|
440
|
+
void verify(el, src, seq);
|
|
441
|
+
}, VERIFY_DELAY);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function onMouseMove(e: MouseEvent): void {
|
|
445
|
+
if (!deps.isEditMode()) return;
|
|
446
|
+
// Moving onto any of our own overlay UI — the pill, the rules card, or the
|
|
447
|
+
// element-tree panel driving highlights of its own — must NOT count as
|
|
448
|
+
// leaving the element, and wins over any pending retarget. (The tree hovers
|
|
449
|
+
// a row to highlight an element; that move must not then hide the pill.)
|
|
450
|
+
// composedPath, not e.target: this listener is on document, which sees a
|
|
451
|
+
// target retargeted to the shadow host for anything inside the overlay.
|
|
452
|
+
if (isOwnUi(e)) {
|
|
453
|
+
cancelHide();
|
|
454
|
+
cancelCardHide();
|
|
455
|
+
cancelSwitch();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const el = nearestSource(e.target);
|
|
459
|
+
if (!el) {
|
|
460
|
+
cancelSwitch();
|
|
461
|
+
scheduleHide(); // grace period instead of instant hide
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
cancelHide();
|
|
465
|
+
if (el === highlighted) {
|
|
466
|
+
cancelSwitch(); // wandered back onto the current element
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// First highlight lands instantly; a *switch* waits out the dwell so the
|
|
470
|
+
// pill doesn't jump to the parent while the mouse travels up to it.
|
|
471
|
+
if (highlighted === null) {
|
|
472
|
+
highlight(el);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (el === switchTarget) return; // countdown to this element already runs
|
|
476
|
+
cancelSwitch();
|
|
477
|
+
switchTarget = el;
|
|
478
|
+
switchTimer = window.setTimeout(() => highlight(el), SWITCH_DELAY);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
document.addEventListener("mousemove", onMouseMove, { passive: true });
|
|
482
|
+
window.addEventListener("scroll", clearHighlight, { passive: true });
|
|
483
|
+
|
|
484
|
+
return { elements: [outline, tooltip], highlight };
|
|
485
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { styled } from "./ui.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The overlay's icon set: stroke paths on a 24px grid, drawn with `currentColor`
|
|
5
|
+
* so every icon inherits the colour of the control it sits in.
|
|
6
|
+
*
|
|
7
|
+
* The paths are adapted from **Lucide** (https://lucide.dev), ISC licensed:
|
|
8
|
+
*
|
|
9
|
+
* Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as
|
|
10
|
+
* part of Feather (MIT). All other copyright (c) for Lucide are held by
|
|
11
|
+
* Lucide Contributors 2022. Licensed under the ISC License.
|
|
12
|
+
*
|
|
13
|
+
* Inlined as one table rather than installed as a package — the overlay has no
|
|
14
|
+
* runtime dependencies and ships no font file, and copying the paths keeps it
|
|
15
|
+
* that way. `svgMarkup` below writes Lucide's own wrapper (24-box, 2px stroke,
|
|
16
|
+
* round caps and joins), so a path pastes in unchanged; four glyphs deviate and
|
|
17
|
+
* each says why at its entry.
|
|
18
|
+
*
|
|
19
|
+
* `icon()` returns a `<span>` built through ui.ts::styled, so an icon is
|
|
20
|
+
* router-exempt (never treated as an editable page element) and carries the
|
|
21
|
+
* `atx-ico` theming hook like everything else in the overlay. Size is set by
|
|
22
|
+
* the caller; 14px is the bar/pill default, 12px suits dense rows.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type IconName =
|
|
26
|
+
| "cursor"
|
|
27
|
+
| "pencil"
|
|
28
|
+
| "file"
|
|
29
|
+
| "pin"
|
|
30
|
+
| "pinOff"
|
|
31
|
+
| "panelTop"
|
|
32
|
+
| "panelBottom"
|
|
33
|
+
| "sidebar"
|
|
34
|
+
| "check"
|
|
35
|
+
| "dot"
|
|
36
|
+
| "spinner"
|
|
37
|
+
| "alert"
|
|
38
|
+
| "x"
|
|
39
|
+
| "plus"
|
|
40
|
+
| "trash"
|
|
41
|
+
| "quote"
|
|
42
|
+
| "link"
|
|
43
|
+
| "chevronRight"
|
|
44
|
+
| "chevronDown"
|
|
45
|
+
| "copy"
|
|
46
|
+
| "code"
|
|
47
|
+
| "external"
|
|
48
|
+
| "settings"
|
|
49
|
+
| "collections"
|
|
50
|
+
| "lock"
|
|
51
|
+
| "image"
|
|
52
|
+
| "search"
|
|
53
|
+
| "upload";
|
|
54
|
+
|
|
55
|
+
/** Path geometry only — the wrapper `<svg>` supplies stroke, width and caps. */
|
|
56
|
+
const PATHS: Record<IconName, string> = {
|
|
57
|
+
// The overlay's own mark: a click-to-edit pointer. Lucide's `mouse-pointer-2`
|
|
58
|
+
// filled — this one is an identity rather than a glyph in a row, and the
|
|
59
|
+
// solid arrow holds up at the 14px the toggle draws it at.
|
|
60
|
+
cursor:
|
|
61
|
+
'<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" fill="currentColor"/>',
|
|
62
|
+
pencil:
|
|
63
|
+
'<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
|
64
|
+
// file-text
|
|
65
|
+
file: '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
|
66
|
+
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
|
67
|
+
pinOff:
|
|
68
|
+
'<path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>',
|
|
69
|
+
// panel-top / panel-bottom / panel-left, with the band filled. Lucide's three
|
|
70
|
+
// differ only by where one divider line sits, which is not readable at the
|
|
71
|
+
// 14px the bar draws them at — the fill is what says which edge is docked.
|
|
72
|
+
panelTop:
|
|
73
|
+
'<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M5 3h14a2 2 0 0 1 2 2v4H3V5a2 2 0 0 1 2-2z" fill="currentColor" stroke="none"/>',
|
|
74
|
+
panelBottom:
|
|
75
|
+
'<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="currentColor" stroke="none"/>',
|
|
76
|
+
sidebar:
|
|
77
|
+
'<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M5 3h4v18H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" fill="currentColor" stroke="none"/>',
|
|
78
|
+
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
79
|
+
// Lucide has no filled dot: `circle-small` is a ring at r=6, and filling that
|
|
80
|
+
// gives a disc a third wider than the mark it replaces, out of weight with
|
|
81
|
+
// every other glyph here. This one stays hand-drawn.
|
|
82
|
+
dot: '<circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/>',
|
|
83
|
+
// Two arcs of one ring rather than Lucide's single `loader-circle` sweep: the
|
|
84
|
+
// dim half is what makes the rotation readable.
|
|
85
|
+
spinner:
|
|
86
|
+
'<path d="M12 3a9 9 0 1 0 9 9" opacity="0.9"/><path d="M12 3a9 9 0 0 1 9 9" opacity="0.25"/>',
|
|
87
|
+
// triangle-alert
|
|
88
|
+
alert:
|
|
89
|
+
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
|
90
|
+
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
91
|
+
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
|
92
|
+
// trash-2
|
|
93
|
+
trash:
|
|
94
|
+
'<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
|
95
|
+
quote:
|
|
96
|
+
'<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2h-1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H5a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
|
|
97
|
+
// link-2
|
|
98
|
+
link: '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
|
|
99
|
+
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
|
100
|
+
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
|
101
|
+
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
|
102
|
+
// code-xml — the slash is what reads as *source*, not as a pair of brackets.
|
|
103
|
+
code: '<path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/>',
|
|
104
|
+
// external-link
|
|
105
|
+
external:
|
|
106
|
+
'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
|
107
|
+
settings:
|
|
108
|
+
'<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>',
|
|
109
|
+
// database: a collection *is* the content store behind the pages, so the
|
|
110
|
+
// stacked-cylinder mark is the right one.
|
|
111
|
+
collections:
|
|
112
|
+
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
|
|
113
|
+
// A closed padlock — marks a setting the project's own config owns.
|
|
114
|
+
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
115
|
+
image:
|
|
116
|
+
'<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
|
117
|
+
search: '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>',
|
|
118
|
+
upload:
|
|
119
|
+
'<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
|
123
|
+
|
|
124
|
+
function svgMarkup(name: IconName, size: number): string {
|
|
125
|
+
return (
|
|
126
|
+
`<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" ` +
|
|
127
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
|
|
128
|
+
`stroke-linejoin="round" aria-hidden="true" focusable="false">${PATHS[name]}</svg>`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** An icon element, ready to append into a button, row or label. */
|
|
133
|
+
export function icon(name: IconName, size = 16): HTMLElement {
|
|
134
|
+
const host = styled("span", "atx-ico");
|
|
135
|
+
paint(host, name, size);
|
|
136
|
+
return host;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Swap an existing icon element's glyph in place (same node, same styling). */
|
|
140
|
+
export function setIcon(host: HTMLElement, name: IconName, size = 16): void {
|
|
141
|
+
if (host.dataset.icon === name) return;
|
|
142
|
+
paint(host, name, size);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function paint(host: HTMLElement, name: IconName, size: number): void {
|
|
146
|
+
host.dataset.icon = name;
|
|
147
|
+
// Static, hand-authored markup from the table above — no interpolation of
|
|
148
|
+
// anything the page or the server supplied.
|
|
149
|
+
host.innerHTML = svgMarkup(name, size);
|
|
150
|
+
for (const anim of host.getAnimations()) anim.cancel();
|
|
151
|
+
if (name === "spinner" && !window.matchMedia(REDUCED_MOTION).matches) {
|
|
152
|
+
host.animate(
|
|
153
|
+
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
|
|
154
|
+
{
|
|
155
|
+
duration: 850,
|
|
156
|
+
iterations: Infinity,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|