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,198 @@
|
|
|
1
|
+
import type { ClassifyResult, SourceLoc } from '../shared/protocol.ts';
|
|
2
|
+
import * as api from './api.ts';
|
|
3
|
+
import { beginExpressionEdit } from './editors/expression.ts';
|
|
4
|
+
import { beginImageEdit } from './editors/image.ts';
|
|
5
|
+
import { beginMarkupEdit } from './editors/markup.ts';
|
|
6
|
+
import type { NoticeOptions } from './editors/notice.ts';
|
|
7
|
+
import { showDynamicNotice } from './editors/notice.ts';
|
|
8
|
+
import { beginTextEdit } from './editors/text.ts';
|
|
9
|
+
import { isOwnUi } from './shadow.ts';
|
|
10
|
+
import { nearestSource, sourceFor } from './source-map.ts';
|
|
11
|
+
import * as state from './state.ts';
|
|
12
|
+
import { toast } from './ui.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Click routing: confirm the clicked target against the server's AST-truth
|
|
16
|
+
* classification, then open the matching editor. Capture-phase listeners
|
|
17
|
+
* swallow clicks on editable targets so wrapping links/buttons can't act.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface RouterDeps {
|
|
21
|
+
isEditMode(): boolean;
|
|
22
|
+
/** True while the navigate modifier (Ctrl / Alt) is held in edit mode. */
|
|
23
|
+
isNavigating(): boolean;
|
|
24
|
+
openSource(src: SourceLoc): void;
|
|
25
|
+
/** Open the in-browser source-peek panel for a loc. */
|
|
26
|
+
openPeek(src: SourceLoc): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const IS_MAC = /Mac|iP(hone|ad|od)/.test(navigator.platform);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In navigate mode, give links plain-click behavior despite the held
|
|
33
|
+
* modifier: natively Alt+click downloads the target and Ctrl+click opens a
|
|
34
|
+
* new tab, so we swallow the event and navigate ourselves. Non-link targets
|
|
35
|
+
* (buttons, form controls) pass through untouched.
|
|
36
|
+
*/
|
|
37
|
+
function navigateThrough(e: MouseEvent): void {
|
|
38
|
+
const a = e.target instanceof Element ? e.target.closest('a[href]') : null;
|
|
39
|
+
if (!(a instanceof HTMLAnchorElement)) return;
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
e.stopImmediatePropagation();
|
|
43
|
+
window.location.assign(a.href);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What initRouter hands back: a programmatic open so the element-tree panel can
|
|
47
|
+
* open the editor for a row (double-click) through the same classify → route
|
|
48
|
+
* path a page click takes. */
|
|
49
|
+
export interface RouterHandle {
|
|
50
|
+
openElementAt(el: HTMLElement): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Register the capture-phase click/mousedown listeners. */
|
|
54
|
+
export function initRouter(deps: RouterDeps): RouterHandle {
|
|
55
|
+
/**
|
|
56
|
+
* Open the appropriate editor for a source-mapped element — after confirming
|
|
57
|
+
* the target with the server's AST classification. The DOM can't distinguish
|
|
58
|
+
* a resolved {expression} from literal text; the AST can. (spec §16.1)
|
|
59
|
+
*/
|
|
60
|
+
async function openElement(
|
|
61
|
+
el: HTMLElement,
|
|
62
|
+
src: SourceLoc,
|
|
63
|
+
/**
|
|
64
|
+
* What the pointer actually landed on, when that is not `el`. Astro
|
|
65
|
+
* annotates only elements written in the file, so a component's or a
|
|
66
|
+
* slot's output has no loc of its own and `nearestSource` climbs to an
|
|
67
|
+
* ancestor — whose reason then describes an element the user never
|
|
68
|
+
* clicked. Carried through so the refusal can say which is which.
|
|
69
|
+
*/
|
|
70
|
+
clicked?: EventTarget | null,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const via: NoticeOptions =
|
|
73
|
+
clicked instanceof Element && clicked !== el
|
|
74
|
+
? { clickedTag: clicked.tagName.toLowerCase() }
|
|
75
|
+
: {};
|
|
76
|
+
// Claim the interaction slot synchronously: /classify is async, and without
|
|
77
|
+
// this a rapid second click during the round-trip could open a second editor.
|
|
78
|
+
const busy = state.begin({ kind: 'busy' });
|
|
79
|
+
let server: ClassifyResult;
|
|
80
|
+
try {
|
|
81
|
+
server = await api.classify({ file: src.file, loc: src.loc, tag: el.tagName.toLowerCase() });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
state.releaseIf(busy);
|
|
84
|
+
toast(`Could not check editability — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (server.kind === 'image' && el instanceof HTMLImageElement) {
|
|
89
|
+
const attrs = server.attrs ?? { src: 'dynamic', alt: 'dynamic' };
|
|
90
|
+
if (attrs.src !== 'static' && attrs.alt === 'dynamic') {
|
|
91
|
+
showDynamicNotice(
|
|
92
|
+
src,
|
|
93
|
+
'Both the image file and its alt text are set from code, so they must be edited in the source.',
|
|
94
|
+
deps.openSource,
|
|
95
|
+
deps.openPeek,
|
|
96
|
+
via,
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
void beginImageEdit(el, src, attrs);
|
|
101
|
+
} else if (server.kind === 'text') {
|
|
102
|
+
beginTextEdit(el, src);
|
|
103
|
+
} else if (server.kind === 'markup' && server.markup) {
|
|
104
|
+
beginMarkupEdit(el, src, server.markup.html, deps.openSource);
|
|
105
|
+
} else if (server.kind === 'expression' && server.expression) {
|
|
106
|
+
beginExpressionEdit(el, src, server.expression, deps.openSource);
|
|
107
|
+
} else {
|
|
108
|
+
showDynamicNotice(
|
|
109
|
+
src,
|
|
110
|
+
server.reason ??
|
|
111
|
+
'This content is generated from a template expression or a loop, so editing it here could change behaviour, not just words. Edit it at the source instead.',
|
|
112
|
+
deps.openSource,
|
|
113
|
+
deps.openPeek,
|
|
114
|
+
via,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function onClick(e: MouseEvent): void {
|
|
120
|
+
if (!deps.isEditMode()) return;
|
|
121
|
+
if (isOwnUi(e)) return;
|
|
122
|
+
|
|
123
|
+
// Navigate mode: don't intercept anything, but fix up link clicks whose
|
|
124
|
+
// native modifier behavior isn't "navigate here".
|
|
125
|
+
if (deps.isNavigating()) {
|
|
126
|
+
navigateThrough(e);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const el = nearestSource(e.target);
|
|
131
|
+
const src = el ? sourceFor(el) : undefined;
|
|
132
|
+
|
|
133
|
+
// Case A: an inline text edit is open. A click elsewhere should commit it
|
|
134
|
+
// and — if it landed on another editable target — open that one in the
|
|
135
|
+
// SAME gesture, rather than only dismissing and forcing a second click. (#1)
|
|
136
|
+
const interaction = state.get();
|
|
137
|
+
if (interaction?.kind === 'text') {
|
|
138
|
+
const active = document.querySelector('[data-astro-dev-edit-active="1"]');
|
|
139
|
+
if (el && el === active) return; // clicking within the edit: leave it be
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
e.stopPropagation();
|
|
142
|
+
e.stopImmediatePropagation();
|
|
143
|
+
interaction.finish(true); // commit the current edit now
|
|
144
|
+
// and open the new target immediately
|
|
145
|
+
if (el && src) void openElement(el, src, e.target);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Case B: a modal interaction (image/dynamic panel) is open — its own
|
|
150
|
+
// backdrop/buttons handle dismissal — or a classify/save is in flight.
|
|
151
|
+
// Don't route background clicks.
|
|
152
|
+
if (interaction) return;
|
|
153
|
+
|
|
154
|
+
if (!el || !src) return;
|
|
155
|
+
|
|
156
|
+
// Fully swallow the click so a wrapping <a>/<button> can't navigate or submit.
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
e.stopPropagation();
|
|
159
|
+
e.stopImmediatePropagation();
|
|
160
|
+
void openElement(el, src, e.target);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// In edit mode, also swallow mousedown on an editable target in capture
|
|
164
|
+
// phase — some browsers begin navigation/focus on mousedown before click
|
|
165
|
+
// fires. This stops a wrapping link from acting; our own UI is exempt.
|
|
166
|
+
function onMouseDown(e: MouseEvent): void {
|
|
167
|
+
if (!deps.isEditMode() || deps.isNavigating() || isOwnUi(e)) return;
|
|
168
|
+
const el = nearestSource(e.target);
|
|
169
|
+
if (el && sourceFor(el)) {
|
|
170
|
+
// Don't preventDefault when the mousedown is inside the active edit, or
|
|
171
|
+
// the caret won't move where the user clicked.
|
|
172
|
+
if (el.dataset.astroDevEditActive === '1') return;
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
e.stopPropagation();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// macOS turns Ctrl+click into a right-click — no click event ever fires,
|
|
179
|
+
// only contextmenu. In navigate mode, catch it on links and navigate
|
|
180
|
+
// instead of showing the menu; anywhere else the menu opens as usual.
|
|
181
|
+
function onContextMenu(e: MouseEvent): void {
|
|
182
|
+
if (!IS_MAC || !e.ctrlKey) return;
|
|
183
|
+
if (!deps.isEditMode() || !deps.isNavigating() || isOwnUi(e)) return;
|
|
184
|
+
navigateThrough(e);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Capture phase so we intercept before links/buttons act on their default.
|
|
188
|
+
document.addEventListener('click', onClick, true);
|
|
189
|
+
document.addEventListener('mousedown', onMouseDown, true);
|
|
190
|
+
document.addEventListener('contextmenu', onContextMenu, true);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
openElementAt(el: HTMLElement): void {
|
|
194
|
+
const src = sourceFor(el);
|
|
195
|
+
if (src) void openElement(el, src);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay's shadow root — one host element, one stylesheet, one place that
|
|
3
|
+
* knows the boundary exists.
|
|
4
|
+
*
|
|
5
|
+
* Every piece of chrome the overlay draws lives in here. Host-page CSS cannot
|
|
6
|
+
* match a node inside a shadow root in either direction, which is the isolation
|
|
7
|
+
* the overlay needs on an arbitrary site: a `*{color:red!important}` rule on the
|
|
8
|
+
* page cannot reach us, because it cannot make the match in the first place.
|
|
9
|
+
* Astro's own dev toolbar solves the same problem the same way, in the same
|
|
10
|
+
* page.
|
|
11
|
+
*
|
|
12
|
+
* Inheritance is the one thing that *does* cross, so `overlayCss()` opens with
|
|
13
|
+
* `:host { all: initial }` and re-declares what we want inherited. That is the
|
|
14
|
+
* whole guard — see styles.ts.
|
|
15
|
+
*
|
|
16
|
+
* **Created lazily, never at module evaluation.** `ui.ts` imports this module,
|
|
17
|
+
* and `tests/contrast.test.ts` imports `ui.ts` in Node where there is no
|
|
18
|
+
* `document`. Nothing here may touch the DOM until something actually mounts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { overlayCss } from './styles.ts';
|
|
22
|
+
|
|
23
|
+
/** The custom element name. Also the selector users write to theme the overlay
|
|
24
|
+
* (`astro-dev-edit { --atx-card: … }`), so it is a public name — see
|
|
25
|
+
* docs/STYLING.md. Never registered with `customElements.define`: it needs no
|
|
26
|
+
* behavior, and an unknown element is styleable all the same. */
|
|
27
|
+
const HOST_TAG = 'astro-dev-edit';
|
|
28
|
+
|
|
29
|
+
let host: HTMLElement | null = null;
|
|
30
|
+
let root: ShadowRoot | null = null;
|
|
31
|
+
|
|
32
|
+
function ensure(): ShadowRoot {
|
|
33
|
+
if (root) return root;
|
|
34
|
+
host = document.createElement(HOST_TAG);
|
|
35
|
+
// `display: contents` (set in overlayCss) means the host generates no box at
|
|
36
|
+
// all, so it cannot shift the page's layout by so much as a line box, and
|
|
37
|
+
// every absolutely-positioned child still resolves against the initial
|
|
38
|
+
// containing block exactly as it did when it was a child of <body>.
|
|
39
|
+
// Deliberately *not* position/transform/filter/contain: any of those would
|
|
40
|
+
// make the host a containing block and re-anchor our `position: fixed`
|
|
41
|
+
// panels to it.
|
|
42
|
+
document.body.append(host);
|
|
43
|
+
root = host.attachShadow({ mode: 'open' });
|
|
44
|
+
// `open` on purpose: a closed root buys no security — anything on the page can
|
|
45
|
+
// already read our source — and costs the ability to debug the overlay in
|
|
46
|
+
// DevTools.
|
|
47
|
+
const sheet = new CSSStyleSheet();
|
|
48
|
+
sheet.replaceSync(overlayCss());
|
|
49
|
+
root.adoptedStyleSheets = [sheet];
|
|
50
|
+
return root;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The shadow root. Query it instead of `document` — `document.querySelector`
|
|
54
|
+
* does not cross the boundary. */
|
|
55
|
+
export function overlayRoot(): ShadowRoot {
|
|
56
|
+
return ensure();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The host element. Needed for `composedPath().includes(host)` and as the
|
|
60
|
+
* parent for light-DOM content that is slotted back in (see `mountLight`). */
|
|
61
|
+
export function overlayHost(): HTMLElement {
|
|
62
|
+
ensure();
|
|
63
|
+
return host as HTMLElement;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Mount overlay chrome. The shadow-root replacement for `document.body.append`
|
|
67
|
+
* — every panel, drawer, bar and toast goes through here. */
|
|
68
|
+
export function mount(...nodes: Node[]): void {
|
|
69
|
+
ensure().append(...nodes);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Mount a node into the **light DOM**, as a child of the host, for `<slot>`-ing
|
|
74
|
+
* back into the shadow tree.
|
|
75
|
+
*
|
|
76
|
+
* Exactly one thing needs this: the rich-text editor's `contenteditable`.
|
|
77
|
+
* Safari's selection and `execCommand` APIs are inert against a node inside a
|
|
78
|
+
* shadow root — `document.execCommand` silently does nothing and
|
|
79
|
+
* `getSelection().anchorNode` retargets to `<body>` — which would kill the
|
|
80
|
+
* markdown toolbar outright. A slotted node is composed into the shadow tree
|
|
81
|
+
* for layout while remaining a light-DOM node, so selection keeps working in
|
|
82
|
+
* every engine. Verified in Chrome, Firefox and WebKit.
|
|
83
|
+
*
|
|
84
|
+
* The cost is that host-page CSS *can* reach a slotted node. That is unchanged
|
|
85
|
+
* from how the editor already worked (its `CONTENT_CSS` is a document-level
|
|
86
|
+
* stylesheet), and it is the reason `content-visibility` of the RTE is styled
|
|
87
|
+
* there rather than in `overlayCss()`.
|
|
88
|
+
*/
|
|
89
|
+
export function mountLight(node: Node): void {
|
|
90
|
+
ensure();
|
|
91
|
+
(host as HTMLElement).append(node);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True when an event originated inside the overlay's own UI.
|
|
96
|
+
*
|
|
97
|
+
* `composedPath()` rather than `event.target`: a listener on `document` sees a
|
|
98
|
+
* target retargeted to the host element, so any `target.closest(...)` test
|
|
99
|
+
* silently starts answering wrong once the chrome moves in here. The composed
|
|
100
|
+
* path is the real path, and it includes slotted light-DOM nodes — which
|
|
101
|
+
* `root.contains()` would not.
|
|
102
|
+
*/
|
|
103
|
+
export function isOwnUi(e: Event): boolean {
|
|
104
|
+
return host !== null && e.composedPath().includes(host);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The focused element *inside* the overlay, or null.
|
|
108
|
+
* `document.activeElement` reports the host for anything in here. */
|
|
109
|
+
export function overlayActiveElement(): Element | null {
|
|
110
|
+
return root ? root.activeElement : null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { SourceLoc } from '../shared/protocol.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Source-location capture cache.
|
|
5
|
+
*
|
|
6
|
+
* Astro emits `data-astro-source-file` / `-loc` in the served HTML, but its
|
|
7
|
+
* dev-toolbar runtime STRIPS those attributes out of the DOM shortly after
|
|
8
|
+
* hydration. By hover time they are gone — querying them live finds nothing.
|
|
9
|
+
* (Verified against Astro 5.18: 254 attrs in served HTML, 0 in the live DOM.)
|
|
10
|
+
*
|
|
11
|
+
* So we snapshot every annotated element the instant it appears, before the
|
|
12
|
+
* toolbar clears them, and read hover/edit locations from the cache instead of
|
|
13
|
+
* from live attributes. This is the same approach the astro-click-to-source
|
|
14
|
+
* integration uses. It supersedes spec §4.2's "re-read attributes lazily on
|
|
15
|
+
* next hover", which is not viable here.
|
|
16
|
+
*
|
|
17
|
+
* Two-layer cache. The primary key is the element itself: when we see an
|
|
18
|
+
* annotated element we copy its {file, loc} onto a private JS property. A JS
|
|
19
|
+
* property survives the attribute-strip (Astro removes the HTML attribute, not
|
|
20
|
+
* our property) AND survives across hover with no path matching. The secondary
|
|
21
|
+
* path-keyed map is the fallback for the case where Astro REPLACES a node
|
|
22
|
+
* wholesale (new object, our property gone): we re-resolve by structural path.
|
|
23
|
+
*
|
|
24
|
+
* The critical timing fix: we don't snapshot once and hope. A MutationObserver
|
|
25
|
+
* watches for the attributes being added (initial render / HMR) and stamps them
|
|
26
|
+
* onto the element the moment they appear — so we always capture the value
|
|
27
|
+
* before the toolbar's own observer strips it, regardless of ordering.
|
|
28
|
+
*
|
|
29
|
+
* TIMING CONTRACT: this module has no top-level side effects. The entry module
|
|
30
|
+
* (overlay.ts) must call startCapture() synchronously at module evaluation to
|
|
31
|
+
* win the race against the toolbar's stripping.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const PROP = '__astroDevEditSrc' as const;
|
|
35
|
+
|
|
36
|
+
interface Stamped extends HTMLElement {
|
|
37
|
+
[PROP]?: SourceLoc;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sourceByPath = new Map<string, SourceLoc>();
|
|
41
|
+
|
|
42
|
+
/** Structural path: `tag:nth-of-type` chain to the document root. Computed from
|
|
43
|
+
* the live DOM at both stamp and lookup time, so the two always agree. */
|
|
44
|
+
function elementPath(el: HTMLElement): string {
|
|
45
|
+
const parts: string[] = [];
|
|
46
|
+
let cur: HTMLElement | null = el;
|
|
47
|
+
while (cur && cur.parentElement) {
|
|
48
|
+
const parent: HTMLElement = cur.parentElement;
|
|
49
|
+
const tag = cur.tagName;
|
|
50
|
+
let idx = 1;
|
|
51
|
+
for (const sib of parent.children) {
|
|
52
|
+
if (sib === cur) break;
|
|
53
|
+
if (sib.tagName === tag) idx++;
|
|
54
|
+
}
|
|
55
|
+
parts.unshift(`${tag.toLowerCase()}:nth-of-type(${idx})`);
|
|
56
|
+
cur = parent;
|
|
57
|
+
}
|
|
58
|
+
return parts.join('>');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Record an annotated element's source loc into both cache layers. */
|
|
62
|
+
function stamp(el: Stamped): void {
|
|
63
|
+
if (el[PROP]) return;
|
|
64
|
+
const file = el.getAttribute('data-astro-source-file');
|
|
65
|
+
if (!file) return;
|
|
66
|
+
const loc = el.getAttribute('data-astro-source-loc') ?? '';
|
|
67
|
+
const src: SourceLoc = { file, loc };
|
|
68
|
+
el[PROP] = src;
|
|
69
|
+
sourceByPath.set(elementPath(el), src);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Snapshot everything currently annotated in the DOM. Called at capture start
|
|
73
|
+
* and again after HMR re-renders (which re-annotate the fresh DOM). */
|
|
74
|
+
export function cacheSourceMappings(): void {
|
|
75
|
+
for (const el of document.querySelectorAll<Stamped>('[data-astro-source-file]')) {
|
|
76
|
+
stamp(el);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every live element that carries a captured source loc, in document order.
|
|
82
|
+
*
|
|
83
|
+
* The `data-astro-source-*` attributes are gone by now (the toolbar stripped
|
|
84
|
+
* them), so we can't query them — we read the stamped JS property instead,
|
|
85
|
+
* which persists. This is the only way to enumerate annotated elements after
|
|
86
|
+
* boot; the element-tree panel derives its structure from it. Assumes the DOM
|
|
87
|
+
* has already been stamped (startCapture / cacheSourceMappings), which the boot
|
|
88
|
+
* and HMR paths guarantee before this is called.
|
|
89
|
+
*/
|
|
90
|
+
export function annotatedElements(root: HTMLElement = document.body): HTMLElement[] {
|
|
91
|
+
const out: HTMLElement[] = [];
|
|
92
|
+
for (const el of root.querySelectorAll<Stamped>('*')) {
|
|
93
|
+
if (el[PROP]) out.push(el);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The structural path used as the cache's fallback key — a stable identity for
|
|
99
|
+
* an element across HMR re-renders (which replace the element object). The tree
|
|
100
|
+
* panel keys its collapse/selection state on this so both survive a save. */
|
|
101
|
+
export function pathFor(el: HTMLElement): string {
|
|
102
|
+
return elementPath(el);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolve a live element's source loc: property first, path fallback. */
|
|
106
|
+
export function sourceFor(el: HTMLElement): SourceLoc | undefined {
|
|
107
|
+
return (el as Stamped)[PROP] ?? sourceByPath.get(elementPath(el));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Nearest ancestor (or self) with a cached source location. Reads the
|
|
112
|
+
* snapshot cache, not live attributes — the attributes are gone by now.
|
|
113
|
+
*/
|
|
114
|
+
export function nearestSource(node: EventTarget | null): HTMLElement | null {
|
|
115
|
+
let el = node as HTMLElement | null;
|
|
116
|
+
while (el && el !== document.body) {
|
|
117
|
+
if (sourceFor(el)) return el;
|
|
118
|
+
el = el.parentElement;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Stamp attributes the instant they appear, before the dev toolbar strips them.
|
|
124
|
+
// This wins the race regardless of script ordering. (verified fix)
|
|
125
|
+
const stampObserver = new MutationObserver((records) => {
|
|
126
|
+
for (const rec of records) {
|
|
127
|
+
if (rec.type === 'attributes' && rec.target instanceof HTMLElement) {
|
|
128
|
+
stamp(rec.target);
|
|
129
|
+
}
|
|
130
|
+
for (const node of rec.addedNodes) {
|
|
131
|
+
if (node instanceof HTMLElement) {
|
|
132
|
+
if (node.hasAttribute('data-astro-source-file')) stamp(node);
|
|
133
|
+
for (const el of node.querySelectorAll<Stamped>('[data-astro-source-file]')) {
|
|
134
|
+
stamp(el);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** Begin capturing: snapshot what's present, then observe for the rest. */
|
|
142
|
+
export function startCapture(): void {
|
|
143
|
+
cacheSourceMappings(); // grab whatever is already present
|
|
144
|
+
stampObserver.observe(document.documentElement, {
|
|
145
|
+
subtree: true,
|
|
146
|
+
childList: true,
|
|
147
|
+
attributes: true,
|
|
148
|
+
attributeFilter: ['data-astro-source-file', 'data-astro-source-loc'],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction state controller — the single slot for "what editing
|
|
3
|
+
* interaction is open right now". Replaces the trio of coordinated flags
|
|
4
|
+
* (editingActive / activeCloser / activeTextFinish) whose invariants every
|
|
5
|
+
* editor had to re-implement.
|
|
6
|
+
*
|
|
7
|
+
* Exactly one interaction can be active:
|
|
8
|
+
* - 'busy' — a /classify round-trip or a save is in flight; clicks are
|
|
9
|
+
* ignored until it settles.
|
|
10
|
+
* - 'text' — an inline contenteditable edit; `finish(commit)` commits or
|
|
11
|
+
* cancels it (cancel restores the original text).
|
|
12
|
+
* - 'panel' — a modal panel (image swap / refusal notice); `close()` tears
|
|
13
|
+
* it down without committing. A panel that holds a draft of its
|
|
14
|
+
* own — the source popups — also supplies `commit()`, so leaving
|
|
15
|
+
* edit mode writes that draft instead of dropping it.
|
|
16
|
+
*
|
|
17
|
+
* The token pattern makes async completion safe: `begin()` returns the
|
|
18
|
+
* interaction as a token, and `releaseIf(token)` only frees the slot if that
|
|
19
|
+
* token still owns it — a commit (or a click-to-re-target) may have started a
|
|
20
|
+
* NEW interaction by the time the previous async save settles. A new editor
|
|
21
|
+
* kind is one `begin()` call; there are no flags to keep consistent.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type Interaction =
|
|
25
|
+
| { kind: 'busy' }
|
|
26
|
+
| { kind: 'text'; finish(commit: boolean): void }
|
|
27
|
+
| { kind: 'panel'; close(): void; commit?(): void };
|
|
28
|
+
|
|
29
|
+
let current: Interaction | null = null;
|
|
30
|
+
|
|
31
|
+
/** The open interaction, or null when idle. */
|
|
32
|
+
export function get(): Interaction | null {
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Claim the slot (replacing whatever held it). Returns the token to release with. */
|
|
37
|
+
export function begin(interaction: Interaction): Interaction {
|
|
38
|
+
current = interaction;
|
|
39
|
+
return interaction;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Free the slot — but only if `token` still owns it. */
|
|
43
|
+
export function releaseIf(token: Interaction): void {
|
|
44
|
+
if (current === token) current = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hand the slot back to whoever held it before `token` claimed it — the
|
|
49
|
+
* counterpart to `begin()` for a **nested** interaction.
|
|
50
|
+
*
|
|
51
|
+
* `begin()` replaces the current holder outright. That is right when one thing
|
|
52
|
+
* is open at a time, but wrong when something opens *above* something else: the
|
|
53
|
+
* media modal can open over the CMS drawer, and a plain `releaseIf` would leave
|
|
54
|
+
* the still-open drawer owning nothing — its Escape and backdrop would silently
|
|
55
|
+
* stop working, with the user's unsaved fields still in it.
|
|
56
|
+
*
|
|
57
|
+
* Capture `get()` immediately before `begin()`, and pass it here on close. If a
|
|
58
|
+
* third interaction has since taken the slot, this does nothing rather than
|
|
59
|
+
* stealing it back.
|
|
60
|
+
*/
|
|
61
|
+
export function releaseTo(token: Interaction, previous: Interaction | null): void {
|
|
62
|
+
if (current !== token) return;
|
|
63
|
+
current = previous;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Tear down whatever is open: a panel closes, a text edit cancels (restoring
|
|
68
|
+
* the original text), a busy marker is simply dropped. The slot is cleared
|
|
69
|
+
* BEFORE the teardown runs, so closers that release their own token can't
|
|
70
|
+
* re-enter.
|
|
71
|
+
*/
|
|
72
|
+
export function dismiss(): void {
|
|
73
|
+
const interaction = current;
|
|
74
|
+
current = null;
|
|
75
|
+
if (!interaction) return;
|
|
76
|
+
if (interaction.kind === 'panel') interaction.close();
|
|
77
|
+
else if (interaction.kind === 'text') interaction.finish(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Close whatever is open, *keeping* the user's work: a text edit commits
|
|
82
|
+
* (writing to disk) instead of reverting; a panel that declared a `commit()`
|
|
83
|
+
* runs it, and one that did not just closes, since it owns its own Save
|
|
84
|
+
* button. The counterpart to `dismiss()` — leaving edit mode must never
|
|
85
|
+
* silently discard typing, so the exit path goes through here.
|
|
86
|
+
*
|
|
87
|
+
* A committing panel may refuse (a save can come back with a reason) and
|
|
88
|
+
* re-claim the slot from inside `commit()`, which is why the slot is cleared
|
|
89
|
+
* first: the panel's own `begin()` then wins, and the exit path reads the save
|
|
90
|
+
* phase to decide whether it may leave.
|
|
91
|
+
*/
|
|
92
|
+
export function commit(): void {
|
|
93
|
+
const interaction = current;
|
|
94
|
+
current = null;
|
|
95
|
+
if (!interaction) return;
|
|
96
|
+
if (interaction.kind === 'panel') (interaction.commit ?? interaction.close)();
|
|
97
|
+
else if (interaction.kind === 'text') interaction.finish(true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Save phase --------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How the on-disk state of the page relates to what's on screen — the admin
|
|
104
|
+
* bar's exit button renders this, so "are my changes saved?" is answerable at a
|
|
105
|
+
* glance:
|
|
106
|
+
*
|
|
107
|
+
* - 'clean' — nothing pending; everything typed has been written
|
|
108
|
+
* - 'dirty' — an inline edit has unsaved keystrokes in it
|
|
109
|
+
* - 'saving' — a write is in flight
|
|
110
|
+
* - 'saved' — a write just landed (decays back to 'clean')
|
|
111
|
+
* - 'error' — the last write failed and its change was rolled back
|
|
112
|
+
*
|
|
113
|
+
* It lives beside the interaction slot because the same editors drive both, but
|
|
114
|
+
* it is deliberately *reported* rather than derived: only the editor knows
|
|
115
|
+
* whether the keystrokes so far differ from the original, and only the commit
|
|
116
|
+
* knows whether the server accepted them.
|
|
117
|
+
*/
|
|
118
|
+
export type SavePhase = 'clean' | 'dirty' | 'saving' | 'saved' | 'error';
|
|
119
|
+
|
|
120
|
+
/** How long 'saved' stays on screen before decaying to 'clean'. */
|
|
121
|
+
const SAVED_LINGER = 1400;
|
|
122
|
+
|
|
123
|
+
let phase: SavePhase = 'clean';
|
|
124
|
+
let decayTimer: number | null = null;
|
|
125
|
+
const phaseListeners = new Set<(p: SavePhase) => void>();
|
|
126
|
+
|
|
127
|
+
export function savePhase(): SavePhase {
|
|
128
|
+
return phase;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Report a phase change. Repeats are dropped, so an editor may call this on
|
|
132
|
+
* every keystroke without waking any listener more than once. */
|
|
133
|
+
export function setSavePhase(next: SavePhase): void {
|
|
134
|
+
if (next === phase) return;
|
|
135
|
+
if (decayTimer !== null) {
|
|
136
|
+
clearTimeout(decayTimer);
|
|
137
|
+
decayTimer = null;
|
|
138
|
+
}
|
|
139
|
+
phase = next;
|
|
140
|
+
for (const fn of phaseListeners) fn(phase);
|
|
141
|
+
if (phase === 'saved') {
|
|
142
|
+
decayTimer = window.setTimeout(() => {
|
|
143
|
+
decayTimer = null;
|
|
144
|
+
setSavePhase('clean');
|
|
145
|
+
}, SAVED_LINGER);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Subscribe to phase changes; returns an unsubscribe. */
|
|
150
|
+
export function onSavePhase(fn: (p: SavePhase) => void): () => void {
|
|
151
|
+
phaseListeners.add(fn);
|
|
152
|
+
return () => phaseListeners.delete(fn);
|
|
153
|
+
}
|