main
js 774 lines 26.7 KB
Raw
1 import { setIconName } from "./icons.js";
2
3 export const SURFACE_MODE_DOCKED = "canvas";
4 export const SURFACE_MODE_FLOATING = "modal";
5 export const SURFACE_MODAL_GROUP = "surfaces";
6
7 const LEGACY_SURFACE_IDS = new Map([
8 ["office", "desktop"],
9 ]);
10
11 const registeredSurfaces = new Map();
12 const urlHandlers = new Set();
13 const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
14
15 export const CORE_SURFACES = [
16 {
17 id: "files",
18 title: "Files",
19 icon: "folder",
20 order: 5,
21 modalPath: "modals/file-browser/file-browser.html",
22 async beginDockHandoff() {
23 const { store } = await import("/components/modals/file-browser/file-browser-store.js");
24 store.beginSurfaceHandoff?.();
25 },
26 async finishDockHandoff(payload = {}) {
27 const { store } = await import("/components/modals/file-browser/file-browser-store.js");
28 store.finishSurfaceHandoff?.(payload);
29 },
30 async cancelDockHandoff() {
31 const { store } = await import("/components/modals/file-browser/file-browser-store.js");
32 store.cancelSurfaceHandoff?.();
33 },
34 async open(payload = {}) {
35 const { store } = await import("/components/modals/file-browser/file-browser-store.js");
36 await store.openSurface(payload.path || payload.filePath || payload.directory || "");
37 },
38 },
39 {
40 id: "browser",
41 title: "Browser",
42 icon: "language",
43 order: 10,
44 modalPath: "/plugins/_browser/webui/main.html",
45 },
46 {
47 id: "desktop",
48 title: "Desktop",
49 icon: "desktop_windows",
50 order: 20,
51 modalPath: "/plugins/_desktop/webui/main.html",
52 },
53 {
54 id: "editor",
55 title: "Editor",
56 icon: "article",
57 order: 30,
58 modalPath: "/plugins/_editor/webui/main.html",
59 },
60 ];
61
62 export function normalizeSurfaceId(surfaceId = "") {
63 const normalized = String(surfaceId || "").trim();
64 return LEGACY_SURFACE_IDS.get(normalized) || normalized;
65 }
66
67 export function normalizeSurfaceMode(mode = "") {
68 return mode === SURFACE_MODE_FLOATING ? SURFACE_MODE_FLOATING : SURFACE_MODE_DOCKED;
69 }
70
71 export function normalizeModalPath(modalPath = "") {
72 return String(modalPath || "").replace(/^\/+/, "");
73 }
74
75 export function sameModalPath(left = "", right = "") {
76 return normalizeModalPath(left) === normalizeModalPath(right);
77 }
78
79 export function migratePersistedSurfaceState(saved = {}) {
80 const result = { ...(saved || {}) };
81 result.activeSurfaceId = normalizeSurfaceId(result.activeSurfaceId || "");
82 result.surfaceModes = migrateSurfaceModeMap(result.surfaceModes || {});
83 return result;
84 }
85
86 function migrateSurfaceModeMap(surfaceModes = {}) {
87 const result = {};
88 for (const [surfaceId, mode] of Object.entries(surfaceModes || {})) {
89 const normalizedId = normalizeSurfaceId(surfaceId);
90 if (!normalizedId) continue;
91 if (result[normalizedId] && normalizedId !== surfaceId) continue;
92 result[normalizedId] = normalizeSurfaceMode(mode);
93 }
94 return result;
95 }
96
97 export function registerSurface(surface = {}) {
98 const id = normalizeSurfaceId(surface.id || "");
99 if (!id) return null;
100 const normalized = {
101 title: id,
102 icon: "web_asset",
103 image: "",
104 order: 100,
105 canOpen: () => true,
106 open: () => {},
107 close: () => {},
108 modalPath: "",
109 actionOnly: false,
110 ...surface,
111 id,
112 };
113 registeredSurfaces.set(id, normalized);
114 return normalized;
115 }
116
117 export function getRegisteredSurfaces() {
118 const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
119 for (const surface of registeredSurfaces.values()) {
120 surfacesById.set(surface.id, surface);
121 }
122 return Array.from(surfacesById.values())
123 .filter((surface) => surface?.id)
124 .sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
125 }
126
127 export function getSurface(surfaceId = "") {
128 const targetId = normalizeSurfaceId(surfaceId);
129 return getRegisteredSurfaces().find((surface) => surface.id === targetId) || null;
130 }
131
132 export function modalSurfaceMetadata(doc, modalPath = "") {
133 const htmlDataset = doc?.documentElement?.dataset || {};
134 const bodyDataset = doc?.body?.dataset || {};
135 const surfaceId = normalizeSurfaceId(
136 htmlDataset.surfaceId
137 || bodyDataset.surfaceId
138 || htmlDataset.canvasSurface
139 || bodyDataset.canvasSurface
140 || "",
141 );
142 if (!surfaceId) return null;
143 return {
144 surfaceId,
145 modalPath: (
146 htmlDataset.surfaceModalPath
147 || bodyDataset.surfaceModalPath
148 || htmlDataset.canvasModalPath
149 || bodyDataset.canvasModalPath
150 || modalPath
151 ),
152 title: (
153 htmlDataset.surfaceDockTitle
154 || bodyDataset.surfaceDockTitle
155 || htmlDataset.canvasDockTitle
156 || bodyDataset.canvasDockTitle
157 || "Open in surface"
158 ),
159 icon: (
160 htmlDataset.surfaceDockIcon
161 || bodyDataset.surfaceDockIcon
162 || htmlDataset.canvasDockIcon
163 || bodyDataset.canvasDockIcon
164 || "dock_to_right"
165 ),
166 };
167 }
168
169 export function modalHasSurfaceMetadata(modalOrElement) {
170 const element = modalOrElement?.element || modalOrElement;
171 return Boolean(
172 element?.dataset?.surfaceId
173 || element?.dataset?.canvasSurface
174 || element?.querySelector?.(".modal-inner")?.dataset?.surfaceId
175 || element?.querySelector?.(".modal-inner")?.dataset?.canvasSurface
176 || modalPathMatchesSurface(modalOrElement?.path || element?.path || ""),
177 );
178 }
179
180 export function modalPathMatchesSurface(path = "") {
181 return getRegisteredSurfaces().some((surface) => sameModalPath(surface.modalPath || "", path));
182 }
183
184 function modalSurfaceDefinition(modalOrElement) {
185 const element = modalOrElement?.element || modalOrElement;
186 const path = typeof modalOrElement === "string"
187 ? modalOrElement
188 : modalOrElement?.path || element?.path || element?.dataset?.modalPath || "";
189 return getRegisteredSurfaces().find((surface) => sameModalPath(surface.modalPath || "", path)) || null;
190 }
191
192 function modalSurfaceGroup(modalOrElement) {
193 return modalSurfaceDefinition(modalOrElement) ? SURFACE_MODAL_GROUP : "";
194 }
195
196 export function shouldSuppressBackdrop(modal) {
197 return Boolean(
198 modalHasSurfaceMetadata(modal)
199 || modal?.element?.classList?.contains("surface-floating")
200 || modal?.element?.classList?.contains("modal-floating")
201 || modal?.element?.classList?.contains("modal-no-backdrop")
202 || modal?.inner?.classList?.contains("surface-modal")
203 || modal?.inner?.classList?.contains("modal-no-backdrop")
204 );
205 }
206
207 function setModalParked(modal, parked = false) {
208 const element = modal?.element;
209 if (!element) return;
210 element.classList.toggle("modal-surface-parked", parked);
211 element.classList.toggle("surface-modal-parked", parked);
212 if (parked) {
213 element.classList.remove("show");
214 element.setAttribute("aria-hidden", "true");
215 } else {
216 element.classList.add("show");
217 element.removeAttribute("aria-hidden");
218 }
219 }
220
221 async function modalApi() {
222 return await import("/js/modals.js");
223 }
224
225 async function parkSiblingSurfaceModals(activeModal) {
226 const group = modalSurfaceGroup(activeModal);
227 if (!group) {
228 setModalParked(activeModal, false);
229 return;
230 }
231
232 const { getModalStack } = await modalApi();
233 for (const modal of getModalStack()) {
234 setModalParked(modal, modal !== activeModal && modalSurfaceGroup(modal) === group);
235 }
236 }
237
238 export async function closeSurfaceGroupModals(options = {}) {
239 const { closeModal, getModalStack, isModalOpen } = await modalApi();
240 const exceptPath = normalizeModalPath(options?.exceptPath || "");
241 const targets = getModalStack()
242 .filter((modal) => modalSurfaceGroup(modal) === SURFACE_MODAL_GROUP)
243 .map((modal) => ({
244 path: modal.path,
245 surface: modalSurfaceDefinition(modal),
246 }))
247 .filter((target) => !exceptPath || normalizeModalPath(target.path) !== exceptPath)
248 .reverse();
249 const handoffPayload = { source: "modal-group-close" };
250 const handoffs = [];
251 let closedAll = false;
252
253 try {
254 for (const target of targets) {
255 if (!target.surface?.beginDockHandoff) continue;
256 await target.surface.beginDockHandoff({ ...handoffPayload, modalPath: target.path });
257 handoffs.push(target.surface);
258 }
259
260 for (const target of targets) {
261 if (!isModalOpen(target.path)) continue;
262 const closed = await closeModal(target.path);
263 if (closed === false) return false;
264 }
265 closedAll = true;
266 return true;
267 } finally {
268 for (const surface of handoffs) {
269 try {
270 if (closedAll) {
271 await surface.finishDockHandoff?.({ ...handoffPayload, opened: false });
272 } else {
273 await surface.cancelDockHandoff?.(handoffPayload);
274 }
275 } catch (error) {
276 console.error("Surface modal group handoff cleanup failed", error);
277 }
278 }
279 }
280 }
281
282 function getModalSwitchSurfaces(metadata) {
283 const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
284 for (const surface of getRegisteredSurfaces()) {
285 if (!surface?.id || !surface.modalPath || surface.actionOnly) continue;
286 surfacesById.set(surface.id, {
287 ...surface,
288 modalPath: surface.modalPath,
289 });
290 }
291
292 if (metadata?.surfaceId && !surfacesById.has(metadata.surfaceId)) {
293 surfacesById.set(metadata.surfaceId, {
294 id: metadata.surfaceId,
295 title: metadata.title,
296 icon: metadata.icon,
297 modalPath: metadata.modalPath,
298 });
299 }
300
301 return Array.from(surfacesById.values())
302 .filter((surface) => surface?.id && surface.modalPath && !surface.actionOnly)
303 .sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
304 }
305
306 function directChildByClass(parent, className) {
307 return Array.from(parent?.children || []).find((child) => child.classList?.contains(className)) || null;
308 }
309
310 function ensureSurfaceModalActionRail(header) {
311 if (!header) return null;
312 let rail = directChildByClass(header, "surface-modal-actions");
313 if (!rail) {
314 rail = document.createElement("div");
315 rail.className = "surface-modal-actions";
316 rail.setAttribute("aria-label", "Surface modal actions");
317
318 const closeButton = directChildByClass(header, "modal-close") || header.querySelector?.(".modal-close");
319 if (closeButton) {
320 closeButton.insertAdjacentElement("beforebegin", rail);
321 } else {
322 header.appendChild(rail);
323 }
324 }
325
326 for (const [index, groupName] of SURFACE_MODAL_ACTION_GROUPS.entries()) {
327 if (!rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`)) {
328 if (index > 0 && !rail.querySelector(`[data-surface-modal-separator-before="${groupName}"]`)) {
329 const separator = document.createElement("span");
330 separator.className = "surface-modal-action-separator";
331 separator.dataset.surfaceModalSeparatorBefore = groupName;
332 separator.setAttribute("aria-hidden", "true");
333 rail.appendChild(separator);
334 }
335
336 const group = document.createElement("div");
337 group.className = `surface-modal-action-group surface-modal-action-group-${groupName}`;
338 group.dataset.surfaceModalActionGroup = groupName;
339 rail.appendChild(group);
340 }
341 }
342
343 refreshSurfaceModalActionRail(header);
344 return rail;
345 }
346
347 function surfaceModalActionGroup(header, groupName) {
348 const rail = ensureSurfaceModalActionRail(header);
349 return rail?.querySelector?.(`[data-surface-modal-action-group="${groupName}"]`) || null;
350 }
351
352 export function refreshSurfaceModalActionRail(header) {
353 const rail = directChildByClass(header, "surface-modal-actions");
354 if (!rail) return;
355
356 const groups = Object.fromEntries(
357 SURFACE_MODAL_ACTION_GROUPS.map((groupName) => [
358 groupName,
359 rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`),
360 ]),
361 );
362 const hasActions = Object.fromEntries(
363 Object.entries(groups).map(([groupName, group]) => [
364 groupName,
365 Boolean(group?.children?.length),
366 ]),
367 );
368
369 for (const [groupName, group] of Object.entries(groups)) {
370 if (group) group.hidden = !hasActions[groupName];
371 }
372
373 const beforeWindow = rail.querySelector('[data-surface-modal-separator-before="window"]');
374 if (beforeWindow) beforeWindow.hidden = !(hasActions.surfaces && (hasActions.window || hasActions.new));
375
376 const beforeNew = rail.querySelector('[data-surface-modal-separator-before="new"]');
377 if (beforeNew) beforeNew.hidden = !(hasActions.window && hasActions.new);
378 }
379
380 export function placeSurfaceModalHeaderAction(header, element, groupName = "window", options = {}) {
381 if (!header || !element) return;
382 const normalizedGroup = SURFACE_MODAL_ACTION_GROUPS.includes(groupName) ? groupName : "window";
383 const group = surfaceModalActionGroup(header, normalizedGroup);
384 if (!group) return;
385
386 if (options.prepend) {
387 if (element.parentElement !== group || group.firstElementChild !== element) {
388 group.insertBefore(element, group.firstElementChild);
389 }
390 } else if (element.parentElement !== group) {
391 group.appendChild(element);
392 }
393
394 refreshSurfaceModalActionRail(header);
395 }
396
397 export function setupFloatingSurfaceModalChrome(options = {}) {
398 const root = options.root || null;
399 const modal = options.modal || root?.closest?.(".modal") || null;
400 const inner = options.inner || modal?.querySelector?.(".modal-inner") || root?.closest?.(".modal-inner") || null;
401 const header = options.header || inner?.querySelector?.(".modal-header") || null;
402 if (!modal || !inner || !header) return () => {};
403
404 const viewportGap = Number.isFinite(Number(options.viewportGap)) ? Number(options.viewportGap) : 8;
405 const minWidth = Number.isFinite(Number(options.minWidth)) ? Number(options.minWidth) : 320;
406 const minHeight = Number.isFinite(Number(options.minHeight)) ? Number(options.minHeight) : 300;
407 const modalClass = String(options.modalClass || "").trim();
408 const focusButtonClass = String(options.focusButtonClass || "").trim();
409 const focusEnabled = options.focus !== false;
410 const focusLabel = options.focusLabel || "Focus mode";
411 const restoreLabel = options.restoreLabel || "Restore size";
412 const onBoundsChange = typeof options.onBoundsChange === "function" ? options.onBoundsChange : null;
413 const onFocusChange = typeof options.onFocusChange === "function" ? options.onFocusChange : null;
414
415 modal.classList.add("surface-floating", "modal-floating");
416 inner.classList.add("surface-modal", "is-draggable-surface-modal");
417 if (modalClass) inner.classList.add(modalClass);
418
419 const viewportWidth = () => Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
420 const viewportHeight = () => Math.max(document.documentElement.clientHeight || 0, globalThis.innerHeight || 0);
421 const availableWidth = () => Math.max(1, viewportWidth() - viewportGap * 2);
422 const availableHeight = () => Math.max(1, viewportHeight() - viewportGap * 2);
423 const currentBounds = () => {
424 const bounds = inner.getBoundingClientRect();
425 return {
426 left: bounds.left,
427 top: bounds.top,
428 width: bounds.width,
429 height: bounds.height,
430 };
431 };
432 const normalizedBounds = (bounds = {}) => {
433 const maxWidth = availableWidth();
434 const maxHeight = availableHeight();
435 const safeMinWidth = Math.min(minWidth, maxWidth);
436 const safeMinHeight = Math.min(minHeight, maxHeight);
437 const width = Math.min(Math.max(safeMinWidth, Number(bounds.width || safeMinWidth)), maxWidth);
438 const height = Math.min(Math.max(safeMinHeight, Number(bounds.height || safeMinHeight)), maxHeight);
439 return {
440 left: Math.min(
441 Math.max(viewportGap, Number(bounds.left || viewportGap)),
442 Math.max(viewportGap, viewportWidth() - width - viewportGap),
443 ),
444 top: Math.min(
445 Math.max(viewportGap, Number(bounds.top || viewportGap)),
446 Math.max(viewportGap, viewportHeight() - height - viewportGap),
447 ),
448 width,
449 height,
450 };
451 };
452 const notifyBoundsChange = () => {
453 try {
454 onBoundsChange?.({
455 ...currentBounds(),
456 focus: inner.classList.contains("is-focus-mode"),
457 });
458 } catch (error) {
459 console.error("Surface modal bounds callback failed", error);
460 }
461 };
462 const setBounds = (bounds = {}) => {
463 const next = normalizedBounds(bounds);
464 inner.style.position = "fixed";
465 inner.style.transform = "none";
466 inner.style.left = `${Math.round(next.left)}px`;
467 inner.style.top = `${Math.round(next.top)}px`;
468 inner.style.width = `${Math.round(next.width)}px`;
469 inner.style.height = `${Math.round(next.height)}px`;
470 inner.style.maxWidth = `${availableWidth()}px`;
471 inner.style.maxHeight = `${availableHeight()}px`;
472 notifyBoundsChange();
473 return next;
474 };
475 const focusBounds = () => ({
476 left: viewportGap,
477 top: viewportGap,
478 width: availableWidth(),
479 height: availableHeight(),
480 });
481 const clampGeometry = () => {
482 if (inner.classList.contains("is-focus-mode")) {
483 setBounds(focusBounds());
484 return;
485 }
486 setBounds(currentBounds());
487 };
488
489 const initialBounds = currentBounds();
490 inner.style.left = `${Math.max(viewportGap, initialBounds.left)}px`;
491 inner.style.top = `${Math.max(viewportGap, initialBounds.top)}px`;
492 inner.style.transform = "none";
493 clampGeometry();
494
495 let drag = null;
496 let resizeObserver = null;
497 let beforeFocusBounds = null;
498 let focusButton = null;
499
500 const updateFocusButton = (active) => {
501 if (!focusButton) return;
502 const label = active ? restoreLabel : focusLabel;
503 focusButton.setAttribute("aria-label", label);
504 focusButton.setAttribute("title", label);
505 focusButton.classList.toggle("is-active", active);
506 const icon = focusButton.querySelector("x-icon");
507 setIconName(icon, active ? "fullscreen_exit" : "fullscreen");
508 };
509 const setFocusMode = (enabled) => {
510 const active = Boolean(enabled);
511 if (active === inner.classList.contains("is-focus-mode")) return;
512 if (active) {
513 beforeFocusBounds = currentBounds();
514 inner.classList.add("is-focus-mode");
515 setBounds(focusBounds());
516 } else {
517 inner.classList.remove("is-focus-mode");
518 setBounds(beforeFocusBounds || currentBounds());
519 beforeFocusBounds = null;
520 }
521 updateFocusButton(active);
522 try {
523 onFocusChange?.(active);
524 } catch (error) {
525 console.error("Surface modal focus callback failed", error);
526 }
527 };
528
529 const onPointerMove = (event) => {
530 if (!drag) return;
531 setBounds({
532 ...currentBounds(),
533 left: drag.left + event.clientX - drag.x,
534 top: drag.top + event.clientY - drag.y,
535 });
536 };
537 const onPointerUp = () => {
538 drag = null;
539 globalThis.removeEventListener("pointermove", onPointerMove);
540 globalThis.removeEventListener("pointerup", onPointerUp);
541 try {
542 header.releasePointerCapture?.(header.__surfaceModalPointerId || 0);
543 } catch {}
544 };
545 const onPointerDown = (event) => {
546 if (event.button !== 0) return;
547 if (event.target?.closest?.("button, input, select, textarea, a, [data-no-modal-drag], .surface-modal-actions")) return;
548 if (inner.classList.contains("is-focus-mode")) return;
549 const bounds = currentBounds();
550 drag = {
551 x: event.clientX,
552 y: event.clientY,
553 left: bounds.left,
554 top: bounds.top,
555 };
556 header.__surfaceModalPointerId = event.pointerId;
557 header.setPointerCapture?.(event.pointerId);
558 globalThis.addEventListener("pointermove", onPointerMove);
559 globalThis.addEventListener("pointerup", onPointerUp);
560 event.preventDefault();
561 };
562 header.addEventListener("pointerdown", onPointerDown);
563
564 if (focusEnabled) {
565 focusButton = globalThis.document.createElement("button");
566 focusButton.type = "button";
567 focusButton.className = ["surface-button", "surface-modal-focus-button", focusButtonClass]
568 .filter(Boolean)
569 .join(" ");
570 focusButton.innerHTML = '<x-icon aria-hidden="true" name="fullscreen"></x-icon>';
571 const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
572 updateFocusButton(false);
573 focusButton.addEventListener("click", onFocusClick);
574 focusButton.__surfaceModalFocusCleanup = () => focusButton.removeEventListener("click", onFocusClick);
575 placeSurfaceModalHeaderAction(header, focusButton, "window");
576 }
577
578 globalThis.addEventListener("resize", clampGeometry);
579 if (globalThis.ResizeObserver) {
580 resizeObserver = new ResizeObserver(clampGeometry);
581 resizeObserver.observe(inner);
582 }
583
584 return () => {
585 focusButton?.__surfaceModalFocusCleanup?.();
586 focusButton?.remove();
587 refreshSurfaceModalActionRail(header);
588 header.removeEventListener("pointerdown", onPointerDown);
589 globalThis.removeEventListener("pointermove", onPointerMove);
590 globalThis.removeEventListener("pointerup", onPointerUp);
591 globalThis.removeEventListener("resize", clampGeometry);
592 resizeObserver?.disconnect?.();
593 inner.classList.remove("is-focus-mode", "is-draggable-surface-modal");
594 };
595 }
596
597 function markSurfaceModal(modal, metadata) {
598 const element = modal?.element;
599 const inner = modal?.inner || element?.querySelector?.(".modal-inner");
600 if (!element || !inner) return;
601 element.dataset.surfaceId = metadata.surfaceId;
602 element.dataset.modalRestore = "surface";
603 element.classList.add("surface-floating", "modal-floating", "modal-no-backdrop", "modal-explicit-close");
604 inner.dataset.modalRestore = "surface";
605 inner.classList.add("surface-modal", "modal-no-backdrop", "modal-explicit-close");
606 }
607
608 function createModalSurfaceButton(surface, metadata, modal) {
609 const title = surface.title || surface.id;
610 const targetModalPath = surface.modalPath || "";
611 const normalizedId = normalizeSurfaceId(surface.id);
612 const isActive = normalizedId === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
613 const button = document.createElement("button");
614 button.type = "button";
615 button.className = "surface-button modal-surface-button";
616 button.dataset.surfaceId = normalizedId;
617 button.dataset.canvasSurface = normalizedId;
618 button.setAttribute("aria-label", title);
619 button.setAttribute("aria-pressed", isActive.toString());
620 if (isActive) button.classList.add("is-active");
621
622 if (surface.image) {
623 const image = document.createElement("img");
624 image.className = "modal-surface-image";
625 image.src = surface.image;
626 image.alt = "";
627 image.setAttribute("aria-hidden", "true");
628 button.appendChild(image);
629 } else {
630 const icon = document.createElement("x-icon");
631 icon.setAttribute("aria-hidden", "true");
632 icon.name = surface.icon || "web_asset";
633 button.appendChild(icon);
634 }
635
636 button.addEventListener("click", async () => {
637 if (button.disabled || isActive || !targetModalPath) return;
638 button.disabled = true;
639 try {
640 await recordMode(normalizedId, SURFACE_MODE_FLOATING);
641 const { ensureModalOpen } = await modalApi();
642 const openPromise = ensureModalOpen(targetModalPath);
643 if (openPromise?.catch) {
644 openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
645 }
646 } finally {
647 if (document.contains(button)) button.disabled = false;
648 }
649 });
650
651 return button;
652 }
653
654 function configureModalSurfaceSwitcher(modal, metadata) {
655 if (!metadata || !modal?.header || modal.header.querySelector(".surface-switcher, .modal-surface-switcher")) {
656 return;
657 }
658
659 const surfaces = getModalSwitchSurfaces(metadata);
660 if (surfaces.length <= 1) return;
661
662 const switcher = document.createElement("div");
663 switcher.className = "surface-switcher modal-surface-switcher";
664 switcher.setAttribute("role", "group");
665 switcher.setAttribute("aria-label", "Modal surfaces");
666
667 for (const surface of surfaces) {
668 switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
669 }
670
671 placeSurfaceModalHeaderAction(modal.header, switcher, "surfaces");
672 }
673
674 function configureModalDockButton(modal, metadata) {
675 if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button")) {
676 return;
677 }
678
679 void recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING);
680
681 const button = document.createElement("button");
682 button.type = "button";
683 button.className = "surface-dock-button modal-dock-button";
684 button.setAttribute("aria-label", metadata.title);
685 const icon = document.createElement("x-icon");
686 icon.setAttribute("aria-hidden", "true");
687 icon.name = metadata.icon;
688 button.appendChild(icon);
689 button.addEventListener("click", async () => {
690 if (button.disabled) return;
691 button.disabled = true;
692 try {
693 await dock(metadata.surfaceId, {
694 modalPath: metadata.modalPath,
695 sourceModalPath: modal.path,
696 source: "modal",
697 closeSourceModal: async () => {
698 const closed = await closeSurfaceGroupModals();
699 if (closed === false) return false;
700 return !document.contains(modal.element);
701 },
702 });
703 } finally {
704 if (document.contains(button)) button.disabled = false;
705 }
706 });
707
708 placeSurfaceModalHeaderAction(modal.header, button, "window", { prepend: true });
709 }
710
711 async function configureSurfaceModal(event) {
712 const { modal, doc } = event?.detail || {};
713 const metadata = modalSurfaceMetadata(doc, modal?.path || "");
714 if (!metadata) return;
715 markSurfaceModal(modal, metadata);
716 configureModalSurfaceSwitcher(modal, metadata);
717 configureModalDockButton(modal, metadata);
718 const { persistRestorableModalStack, refreshModalStack } = await modalApi();
719 refreshModalStack();
720 persistRestorableModalStack?.({ force: true });
721 }
722
723 export async function open(surfaceId = "", payload = {}) {
724 const { store } = await import("/components/canvas/right-canvas-store.js");
725 return await store.open(normalizeSurfaceId(surfaceId), payload);
726 }
727
728 export async function openLatest(surfaceId = "", payload = {}) {
729 const { store } = await import("/components/canvas/right-canvas-store.js");
730 return await store.openLatest(normalizeSurfaceId(surfaceId), payload);
731 }
732
733 export async function dock(surfaceId = "", payload = {}) {
734 const { store } = await import("/components/canvas/right-canvas-store.js");
735 return await store.dockSurface(normalizeSurfaceId(surfaceId), payload);
736 }
737
738 export async function recordMode(surfaceId = "", mode = SURFACE_MODE_DOCKED, options = {}) {
739 const { store } = await import("/components/canvas/right-canvas-store.js");
740 return store.recordSurfaceMode?.(normalizeSurfaceId(surfaceId), normalizeSurfaceMode(mode), options);
741 }
742
743 export function registerUrlHandler(handler) {
744 if (typeof handler !== "function") return () => {};
745 urlHandlers.add(handler);
746 return () => urlHandlers.delete(handler);
747 }
748
749 export async function handleUrlIntent(intent = {}) {
750 for (const handler of Array.from(urlHandlers)) {
751 const handled = await handler(intent);
752 if (handled) return true;
753 }
754 globalThis.dispatchEvent?.(new CustomEvent("surface-url-intent", { detail: intent }));
755 return false;
756 }
757
758 document.addEventListener("modal-content-loaded", (event) => {
759 void configureSurfaceModal(event);
760 });
761
762 document.addEventListener("modal-activated", (event) => {
763 void parkSiblingSurfaceModals(event?.detail?.modal);
764 });
765
766 document.addEventListener("modal-closed", async () => {
767 const { getModalStack, refreshModalStack } = await modalApi();
768 const stack = getModalStack();
769 if (stack.length > 0) {
770 refreshModalStack();
771 }
772 });
773
774 globalThis.closeSurfaceGroupModals = closeSurfaceGroupModals;