main
js 620 lines 19.2 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
3 import { callJsExtensions } from "/js/extensions.js";
4 import {
5 SURFACE_MODE_DOCKED,
6 SURFACE_MODE_FLOATING,
7 closeSurfaceGroupModals,
8 getRegisteredSurfaces,
9 migratePersistedSurfaceState,
10 normalizeSurfaceId,
11 normalizeSurfaceMode,
12 registerSurface as registerSurfaceDefinition,
13 } from "/js/surfaces.js";
14
15 const STORAGE_KEY = "a0.rightCanvas";
16 const SESSION_STORAGE_KEY = "a0.rightCanvas.session";
17 const DEFAULT_WIDTH = 720;
18 const MIN_WIDTH = 0;
19 const DESKTOP_BREAKPOINT = 1200;
20 const MOBILE_BREAKPOINT = 768;
21
22 function clamp(value, min, max) {
23 return Math.min(Math.max(value, min), max);
24 }
25
26 function viewportWidth() {
27 return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
28 }
29
30 function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
31 if (value === null || value === undefined || value === "") return fallback;
32 const width = Number(value);
33 return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
34 }
35
36 function isReloadNavigation() {
37 const navigation = performance.getEntriesByType?.("navigation")?.[0];
38 if (navigation?.type) return navigation.type === "reload";
39 if (!performance.navigation) return false;
40 return performance.navigation?.type === performance.navigation?.TYPE_RELOAD;
41 }
42
43 const model = {
44 surfaces: [],
45 activeSurfaceId: "",
46 surfaceModes: {},
47 mountedSurfaces: {},
48 isOpen: false,
49 width: DEFAULT_WIDTH,
50 isOverlayMode: false,
51 isMobileMode: false,
52 _initialized: false,
53 _registering: false,
54 _rootElement: null,
55 _resizeCleanup: null,
56 _lastPayloadBySurface: {},
57 _restoreOpenRequested: false,
58 _restoreOpenTimer: null,
59 _restoringSurface: false,
60
61 async init(element = null) {
62 if (element) this._rootElement = element;
63 if (this._initialized) {
64 this.applyLayoutState();
65 this.scheduleRestoreOpenSurface();
66 return;
67 }
68
69 this._initialized = true;
70 this.restore();
71 this.updateLayoutMode();
72 this.applyLayoutState();
73 globalThis.addEventListener("resize", () => {
74 this.updateLayoutMode();
75 this.setWidth(this.width, { persist: false });
76 this.applyLayoutState();
77 });
78
79 if (!this._registering) {
80 this._registering = true;
81 await callJsExtensions("surfaces_register", this);
82 await callJsExtensions("right_canvas_register_surfaces", this);
83 this._registering = false;
84 this.ensureActiveSurface();
85 this.scheduleRestoreOpenSurface();
86 }
87 },
88
89 registerSurface(surface) {
90 if (!surface?.id) return;
91 const surfaceId = normalizeSurfaceId(surface.id);
92 const normalized = {
93 title: surface.id,
94 icon: "web_asset",
95 image: "",
96 order: 100,
97 canOpen: () => true,
98 open: () => {},
99 close: () => {},
100 modalPath: "",
101 actionOnly: false,
102 ...surface,
103 id: surfaceId,
104 };
105
106 const index = this.surfaces.findIndex((item) => item.id === normalized.id);
107 if (index >= 0) {
108 this.surfaces.splice(index, 1, normalized);
109 } else {
110 this.surfaces.push(normalized);
111 }
112 if (!this.surfaceModes[normalized.id]) {
113 this.surfaceModes[normalized.id] = SURFACE_MODE_DOCKED;
114 }
115 registerSurfaceDefinition(normalized);
116 this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
117 if (!this._registering) {
118 this.ensureActiveSurface();
119 }
120 },
121
122 ensureActiveSurface() {
123 const panelSurfaces = this.panelSurfaces;
124 if (!panelSurfaces.length) {
125 this.activeSurfaceId = "";
126 return;
127 }
128 if (!panelSurfaces.some((surface) => surface.id === this.activeSurfaceId)) {
129 this.activeSurfaceId = panelSurfaces[0].id;
130 }
131 },
132
133 async open(surfaceId = "", payload = {}) {
134 const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
135 const surface = this.getSurface(targetId);
136 if (!surface) {
137 return false;
138 }
139 if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
140 return false;
141 }
142
143 this._restoreOpenRequested = false;
144 this.cancelRestoreOpenSurface();
145
146 if (surface.actionOnly) {
147 try {
148 await surface.open?.(payload || {});
149 } catch (error) {
150 console.error(`Canvas action ${targetId} failed`, error);
151 }
152 return true;
153 }
154
155 if (!this.shouldRender()) {
156 return await this.openModalSurface(targetId, payload);
157 }
158
159 if (this.isMobileMode) {
160 this.activeSurfaceId = targetId;
161 this.isOpen = false;
162 return await this.openModalSurface(targetId, payload);
163 }
164
165 this.activeSurfaceId = targetId;
166 this.markSurfaceMounted(targetId);
167 this.isOpen = true;
168 this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED, { persist: false });
169 this._lastPayloadBySurface[targetId] = payload || {};
170 this.persist();
171 this.applyLayoutState();
172
173 try {
174 await surface.open?.(payload || {});
175 } catch (error) {
176 console.error(`Canvas surface ${targetId} failed to open`, error);
177 }
178 return true;
179 },
180
181 markSurfaceMounted(surfaceId) {
182 const targetId = normalizeSurfaceId(surfaceId);
183 if (!targetId) return;
184 this.mountedSurfaces = {
185 ...this.mountedSurfaces,
186 [targetId]: true,
187 };
188 },
189
190 markSurfaceUnmounted(surfaceId) {
191 const targetId = normalizeSurfaceId(surfaceId);
192 if (!targetId || !this.mountedSurfaces[targetId]) return;
193 const next = { ...this.mountedSurfaces };
194 delete next[targetId];
195 this.mountedSurfaces = next;
196 },
197
198 mountedSurfaceIds() {
199 return Object.entries(this.mountedSurfaces)
200 .filter(([, mounted]) => mounted)
201 .map(([surfaceId]) => surfaceId);
202 },
203
204 isSurfaceMounted(id) {
205 return Boolean(this.mountedSurfaces[normalizeSurfaceId(id)]);
206 },
207
208 isSurfaceRendered(id) {
209 return Boolean(this.isOpen && this.isSurfaceMounted(id));
210 },
211
212 isSurfaceVisible(id) {
213 const targetId = normalizeSurfaceId(id);
214 return Boolean(this.isOpen && this.activeSurfaceId === targetId && this.isSurfaceMounted(targetId));
215 },
216
217 recordSurfaceMode(surfaceId, mode = SURFACE_MODE_DOCKED, options = {}) {
218 const targetId = normalizeSurfaceId(surfaceId);
219 if (!targetId) return;
220 this.surfaceModes = {
221 ...this.surfaceModes,
222 [targetId]: normalizeSurfaceMode(mode),
223 };
224 if (options.persist !== false) this.persist();
225 },
226
227 latestSurfaceMode(surfaceId) {
228 const targetId = normalizeSurfaceId(surfaceId);
229 return normalizeSurfaceMode(this.surfaceModes[targetId]);
230 },
231
232 async openLatest(surfaceId = "", payload = {}) {
233 const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
234 if (!targetId) return false;
235 if (this.isMobileMode) {
236 return await this.open(targetId, payload);
237 }
238 if (this.latestSurfaceMode(targetId) === SURFACE_MODE_FLOATING) {
239 return await this.openModalSurface(targetId, payload);
240 }
241 return await this.open(targetId, payload);
242 },
243
244 async close() {
245 this._restoreOpenRequested = false;
246 this.cancelRestoreOpenSurface();
247 this.isOpen = false;
248 this.persist();
249 this.applyLayoutState();
250 return true;
251 },
252
253 async dockSurface(surfaceId, payload = {}) {
254 surfaceId = normalizeSurfaceId(surfaceId);
255 if (this.isMobileMode) {
256 return false;
257 }
258 const surface = this.getSurface(surfaceId);
259 if (!surface) {
260 return false;
261 }
262 const modalPath = payload.modalPath || surface.modalPath || "";
263 let handoffStarted = false;
264 try {
265 await surface.beginDockHandoff?.(payload);
266 handoffStarted = true;
267
268 const closed = await this.closeDockSourceModal(payload, modalPath);
269 if (closed === false) {
270 await surface.cancelDockHandoff?.(payload);
271 return false;
272 }
273
274 const openPayload = { ...payload, source: "modal" };
275 delete openPayload.closeSourceModal;
276 const opened = await this.open(surfaceId, openPayload);
277 await surface.finishDockHandoff?.({ ...openPayload, opened });
278 return opened;
279 } catch (error) {
280 if (handoffStarted) {
281 await surface.cancelDockHandoff?.(payload);
282 }
283 console.error(`Canvas surface ${surfaceId} failed to dock`, error);
284 return false;
285 }
286 },
287
288 async closeDockSourceModal(payload = {}, modalPath = "") {
289 if (typeof payload.closeSourceModal === "function") {
290 return (await payload.closeSourceModal()) !== false;
291 }
292
293 const sourceModalPath = payload.sourceModalPath || modalPath;
294 if (sourceModalPath || modalPath) {
295 const closed = await closeSurfaceGroupModals();
296 if (closed === false) return false;
297 if (!sourceModalPath || !globalThis.isModalOpen?.(sourceModalPath)) return true;
298 }
299 if (sourceModalPath && globalThis.isModalOpen?.(sourceModalPath)) {
300 return (await globalThis.closeModal?.(sourceModalPath)) !== false;
301 }
302 if (modalPath && modalPath !== sourceModalPath && globalThis.isModalOpen?.(modalPath)) {
303 return (await globalThis.closeModal?.(modalPath)) !== false;
304 }
305 return true;
306 },
307
308 async undockSurface(surfaceId = "", payload = {}) {
309 const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
310 const surface = this.getSurface(targetId);
311 const modalPath = payload.modalPath || surface?.modalPath || "";
312 if (!surface || !modalPath) return false;
313 const openModal = globalThis.ensureModalOpen || globalThis.openModal;
314 if (!openModal) return false;
315 if (this.activeSurfaceId === targetId) {
316 this.isOpen = false;
317 this.persist();
318 this.applyLayoutState();
319 }
320 this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
321 const modalPromise = openModal(modalPath);
322 if (modalPromise?.catch) {
323 modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
324 }
325 return true;
326 },
327
328 async openModalSurface(surfaceId = "", payload = {}) {
329 const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
330 const surface = this.getSurface(targetId);
331 const modalPath = payload.modalPath || surface?.modalPath || "";
332 if (!surface || !modalPath) return false;
333 const openModal = globalThis.ensureModalOpen || globalThis.openModal;
334 if (!openModal) return false;
335
336 this._restoreOpenRequested = false;
337 this.cancelRestoreOpenSurface();
338
339 if (this.isOpen && this.activeSurfaceId === targetId) {
340 this.isOpen = false;
341 this.persist();
342 this.applyLayoutState();
343 }
344
345 this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
346 const modalPromise = openModal(modalPath);
347 if (modalPromise?.catch) {
348 modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to open as modal`, error));
349 }
350 return true;
351 },
352
353 async undockActiveSurface() {
354 return await this.undockSurface(this.activeSurfaceId);
355 },
356
357 currentSurfaceCanUndock() {
358 return Boolean(this.currentSurface()?.modalPath);
359 },
360
361 async toggle(surfaceId = "", payload = {}) {
362 const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
363 if (this.isOpen && targetId === this.activeSurfaceId) {
364 await this.close();
365 return false;
366 }
367 return await this.open(targetId, payload);
368 },
369
370 async toggleCanvas() {
371 if (!this.shouldRender()) return false;
372 if (this.isMobileMode) {
373 return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "", { source: "mobile-toggle" });
374 }
375 if (this.isOpen) {
376 await this.close();
377 return false;
378 }
379 return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
380 },
381
382 setWidth(px, options = {}) {
383 const { persist = true } = options;
384 const next = clamp(normalizeWidth(px), MIN_WIDTH, this.maxWidth());
385 this.width = next;
386 this.applyLayoutState();
387 if (persist) this.persist();
388 },
389
390 maxWidth() {
391 if (this.isOverlayMode) {
392 return Math.max(MIN_WIDTH, viewportWidth() - 44);
393 }
394
395 const container = this._rootElement?.closest(".container");
396 const rightPanel = document.getElementById("right-panel");
397 const containerRight = container?.getBoundingClientRect().right ?? viewportWidth();
398 const panelLeft = rightPanel?.getBoundingClientRect().left ?? 0;
399 return Math.max(MIN_WIDTH, Math.floor(containerRight - panelLeft));
400 },
401
402 defaultWidth() {
403 return Math.min(DEFAULT_WIDTH, Math.floor(viewportWidth() * 0.45));
404 },
405
406 startResize(event) {
407 if (this.isOverlayMode || this.isMobileMode || !this.isOpen) return;
408 if (event.button !== 0) return;
409 event.preventDefault();
410 this.dispatchResizeEvent("right-canvas-resize-start");
411
412 const onPointerMove = (moveEvent) => {
413 const nextWidth = viewportWidth() - moveEvent.clientX;
414 this.setWidth(nextWidth);
415 };
416 const onPointerUp = () => {
417 globalThis.removeEventListener("pointermove", onPointerMove);
418 globalThis.removeEventListener("pointerup", onPointerUp);
419 globalThis.removeEventListener("pointercancel", onPointerUp);
420 document.body.classList.remove("right-canvas-resizing");
421 this.persist();
422 this.dispatchResizeEvent("right-canvas-resize-end");
423 };
424
425 document.body.classList.add("right-canvas-resizing");
426 globalThis.addEventListener("pointermove", onPointerMove);
427 globalThis.addEventListener("pointerup", onPointerUp);
428 globalThis.addEventListener("pointercancel", onPointerUp);
429 },
430
431 dispatchResizeEvent(name) {
432 try {
433 globalThis.dispatchEvent(new CustomEvent(name, {
434 detail: {
435 width: this.width,
436 activeSurfaceId: this.activeSurfaceId,
437 },
438 }));
439 } catch {
440 // Resize events are an optimization hook for embedded surfaces.
441 }
442 },
443
444 cancelRestoreOpenSurface() {
445 if (!this._restoreOpenTimer) return;
446 globalThis.clearTimeout?.(this._restoreOpenTimer);
447 this._restoreOpenTimer = null;
448 },
449
450 scheduleRestoreOpenSurface() {
451 if (!this._restoreOpenRequested || this._restoreOpenTimer) return;
452 if (!this.shouldRender() || this.isMobileMode) return;
453 this._restoreOpenTimer = globalThis.setTimeout?.(() => {
454 this._restoreOpenTimer = null;
455 this.restoreOpenSurface();
456 }, 0) || null;
457 },
458
459 async restoreOpenSurface() {
460 if (!this._restoreOpenRequested || this._restoringSurface) return false;
461 if (!this.shouldRender() || this.isMobileMode) {
462 return false;
463 }
464
465 const targetId = normalizeSurfaceId(this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
466 if (!targetId || !this.getSurface(targetId)) {
467 return false;
468 }
469
470 this._restoreOpenRequested = false;
471 this._restoringSurface = true;
472 try {
473 return await this.open(targetId, { source: "reload-restore" });
474 } finally {
475 this._restoringSurface = false;
476 }
477 },
478
479 persist() {
480 const state = {
481 isOpen: this.isOpen,
482 activeSurfaceId: this.activeSurfaceId,
483 surfaceModes: this.surfaceModes,
484 width: this.width,
485 };
486 try {
487 localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
488 } catch (error) {
489 console.warn("Could not persist right canvas state", error);
490 }
491 try {
492 sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state));
493 } catch (error) {
494 console.warn("Could not persist right canvas session state", error);
495 }
496 },
497
498 restore() {
499 this.width = this.defaultWidth();
500 try {
501 const saved = migratePersistedSurfaceState(JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"));
502 this.isOpen = false;
503 this.activeSurfaceId = String(saved.activeSurfaceId || "");
504 this.surfaceModes = Object.fromEntries(
505 Object.entries(saved.surfaceModes || {}).map(([surfaceId, mode]) => [
506 surfaceId,
507 normalizeSurfaceMode(mode),
508 ]),
509 );
510 if (Number.isFinite(Number(saved.width))) this.width = Number(saved.width);
511 } catch (error) {
512 console.warn("Could not restore right canvas state", error);
513 }
514
515 if (isReloadNavigation()) {
516 try {
517 const savedSession = migratePersistedSurfaceState(
518 JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || "{}"),
519 );
520 if (savedSession?.activeSurfaceId) this.activeSurfaceId = String(savedSession.activeSurfaceId);
521 if (savedSession?.surfaceModes) {
522 this.surfaceModes = Object.fromEntries(
523 Object.entries(savedSession.surfaceModes || {}).map(([surfaceId, mode]) => [
524 surfaceId,
525 normalizeSurfaceMode(mode),
526 ]),
527 );
528 }
529 if (Number.isFinite(Number(savedSession?.width))) this.width = Number(savedSession.width);
530 this._restoreOpenRequested = Boolean(savedSession?.isOpen && savedSession?.activeSurfaceId);
531 } catch (error) {
532 console.warn("Could not restore right canvas session state", error);
533 sessionStorage.removeItem(SESSION_STORAGE_KEY);
534 }
535 } else {
536 sessionStorage.removeItem(SESSION_STORAGE_KEY);
537 }
538
539 this.setWidth(this.width, { persist: false });
540 },
541
542 updateLayoutMode() {
543 const width = viewportWidth();
544 const wasMobileMode = this.isMobileMode;
545 this.isOverlayMode = width < DESKTOP_BREAKPOINT;
546 this.isMobileMode = width <= MOBILE_BREAKPOINT;
547 if (this.isMobileMode) {
548 const wasOpen = this.isOpen;
549 const mountedIds = this.mountedSurfaceIds();
550 this.isOpen = false;
551 this.mountedSurfaces = {};
552 if ((wasOpen || mountedIds.length > 0) && mountedIds.length > 0) {
553 globalThis.setTimeout?.(() => {
554 for (const surfaceId of mountedIds) {
555 const surface = this.getSurface(surfaceId);
556 const payload = this._lastPayloadBySurface[surfaceId] || {};
557 surface?.close?.({ ...payload, reason: "mobile" });
558 }
559 }, 0);
560 }
561 } else if (wasMobileMode && this.width < MIN_WIDTH) {
562 this.width = this.defaultWidth();
563 }
564 },
565
566 applyLayoutState() {
567 this.updateLayoutMode();
568 document.documentElement.style.setProperty("--right-canvas-width", `${this.width}px`);
569 document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode && this.shouldRender());
570 document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
571 document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
572 this.scheduleRestoreOpenSurface();
573 },
574
575 widthStyle() {
576 if (this.isMobileMode) return "";
577 if (!this.isOpen) return "width: 0;";
578 if (this.isOverlayMode) {
579 return `width: min(${this.width}px, calc(100vw - 44px));`;
580 }
581 return `width: ${this.width}px;`;
582 },
583
584 getSurface(id) {
585 const targetId = normalizeSurfaceId(id);
586 return this.surfaces.find((surface) => surface.id === targetId)
587 || getRegisteredSurfaces().find((surface) => surface.id === targetId)
588 || null;
589 },
590
591 get railSurfaces() {
592 return this.surfaces;
593 },
594
595 get panelSurfaces() {
596 return this.surfaces.filter((surface) => !surface.actionOnly);
597 },
598
599 currentSurface() {
600 return this.getSurface(this.activeSurfaceId);
601 },
602
603 isSurfaceActive(id) {
604 return this.activeSurfaceId === normalizeSurfaceId(id);
605 },
606
607 activeTitle() {
608 return this.currentSurface()?.title || "Canvas";
609 },
610
611 isWelcomeVisible() {
612 return !chatsStore.selected;
613 },
614
615 shouldRender() {
616 return !this.isWelcomeVisible();
617 },
618 };
619
620 export const store = createStore("rightCanvas", model);