Add universal right canvas shell

Alessandro committed Apr 26, 2026 at 12:56 UTC c9f1bd21ca2d0b03950f792a624499dd3d8f72cf
8 files changed +791 -6
tests/test_webui_extension_surfaces.py
+8
@@ -52,6 +52,14 @@ SURFACE_SCENARIOS: list[tuple[str, str]] = [
52 ("plugins-list-dropdown-end", "webui/components/plugins/list/plugin-list.html"),
53 ("modal-shell-start", "webui/js/modals.js"),
54 ("modal-shell-end", "webui/js/modals.js"),
55 + ("right-canvas-shell-start", "webui/components/canvas/right-canvas.html"),
56 + ("right-canvas-tabs-start", "webui/components/canvas/right-canvas.html"),
57 + ("right-canvas-tabs-end", "webui/components/canvas/right-canvas.html"),
58 + ("right-canvas-toolbar-start", "webui/components/canvas/right-canvas.html"),
59 + ("right-canvas-toolbar-end", "webui/components/canvas/right-canvas.html"),
60 + ("right-canvas-panels", "webui/components/canvas/right-canvas.html"),
61 + ("right-canvas-empty-state", "webui/components/canvas/right-canvas.html"),
62 + ("right-canvas-shell-end", "webui/components/canvas/right-canvas.html"),
63 ]
64
65
webui/components/canvas/right-canvas-store.js new
+283
@@ -0,0 +1,283 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsExtensions } from "/js/extensions.js";
3 +
4 +const STORAGE_KEY = "a0.rightCanvas";
5 +const DEFAULT_WIDTH = 720;
6 +const MIN_WIDTH = 420;
7 +const MAX_WIDTH = 900;
8 +const DESKTOP_BREAKPOINT = 1200;
9 +const MOBILE_BREAKPOINT = 768;
10 +
11 +function clamp(value, min, max) {
12 + return Math.min(Math.max(value, min), max);
13 +}
14 +
15 +function viewportWidth() {
16 + return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
17 +}
18 +
19 +const model = {
20 + surfaces: [],
21 + activeSurfaceId: "",
22 + isOpen: false,
23 + width: DEFAULT_WIDTH,
24 + isOverlayMode: false,
25 + isMobileMode: false,
26 + _initialized: false,
27 + _registering: false,
28 + _rootElement: null,
29 + _resizeCleanup: null,
30 + _lastPayloadBySurface: {},
31 +
32 + async init(element = null) {
33 + if (element) this._rootElement = element;
34 + if (this._initialized) {
35 + this.applyLayoutState();
36 + return;
37 + }
38 +
39 + this._initialized = true;
40 + this.restore();
41 + this.updateLayoutMode();
42 + this.applyLayoutState();
43 + globalThis.addEventListener("resize", () => {
44 + this.updateLayoutMode();
45 + this.setWidth(this.width, { persist: false });
46 + this.applyLayoutState();
47 + });
48 +
49 + if (!this._registering) {
50 + this._registering = true;
51 + await callJsExtensions("right_canvas_register_surfaces", this);
52 + this._registering = false;
53 + this.ensureActiveSurface();
54 + if (this.isOpen && this.activeSurfaceId) {
55 + globalThis.requestAnimationFrame?.(() => {
56 + void this.open(this.activeSurfaceId, this._lastPayloadBySurface[this.activeSurfaceId] || {});
57 + });
58 + }
59 + }
60 + },
61 +
62 + registerSurface(surface) {
63 + if (!surface?.id) return;
64 + const normalized = {
65 + title: surface.id,
66 + icon: "web_asset",
67 + order: 100,
68 + canOpen: () => true,
69 + open: () => {},
70 + close: () => {},
71 + modalPath: "",
72 + ...surface,
73 + };
74 +
75 + const index = this.surfaces.findIndex((item) => item.id === normalized.id);
76 + if (index >= 0) {
77 + this.surfaces.splice(index, 1, normalized);
78 + } else {
79 + this.surfaces.push(normalized);
80 + }
81 + this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
82 + this.ensureActiveSurface();
83 + },
84 +
85 + ensureActiveSurface() {
86 + if (!this.surfaces.length) {
87 + this.activeSurfaceId = "";
88 + return;
89 + }
90 + if (!this.surfaces.some((surface) => surface.id === this.activeSurfaceId)) {
91 + this.activeSurfaceId = this.surfaces[0].id;
92 + }
93 + },
94 +
95 + async open(surfaceId = "", payload = {}) {
96 + const targetId = surfaceId || this.activeSurfaceId || this.surfaces[0]?.id || "";
97 + const surface = this.getSurface(targetId);
98 + if (!surface) return false;
99 + if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
100 + return false;
101 + }
102 +
103 + this.activeSurfaceId = targetId;
104 + this.isOpen = true;
105 + this._lastPayloadBySurface[targetId] = payload || {};
106 + this.persist();
107 + this.applyLayoutState();
108 +
109 + try {
110 + await surface.open?.(payload || {});
111 + } catch (error) {
112 + console.error(`Canvas surface ${targetId} failed to open`, error);
113 + }
114 + return true;
115 + },
116 +
117 + async close() {
118 + const surface = this.currentSurface();
119 + this.isOpen = false;
120 + this.persist();
121 + this.applyLayoutState();
122 + try {
123 + await surface?.close?.(this._lastPayloadBySurface[this.activeSurfaceId] || {});
124 + } catch (error) {
125 + console.error(`Canvas surface ${this.activeSurfaceId} failed to close`, error);
126 + }
127 + },
128 +
129 + async dockSurface(surfaceId, payload = {}) {
130 + const surface = this.getSurface(surfaceId);
131 + if (!surface) return false;
132 + const modalPath = payload.modalPath || surface.modalPath || "";
133 + if (modalPath && globalThis.isModalOpen?.(modalPath)) {
134 + await globalThis.closeModal?.(modalPath);
135 + }
136 + return await this.open(surfaceId, { ...payload, source: "modal" });
137 + },
138 +
139 + async undockSurface(surfaceId = "", payload = {}) {
140 + const targetId = surfaceId || this.activeSurfaceId;
141 + const surface = this.getSurface(targetId);
142 + const modalPath = payload.modalPath || surface?.modalPath || "";
143 + if (!surface || !modalPath) return false;
144 + if (this.activeSurfaceId === targetId) {
145 + this.isOpen = false;
146 + this.persist();
147 + this.applyLayoutState();
148 + try {
149 + await surface.close?.(this._lastPayloadBySurface[targetId] || {});
150 + } catch (error) {
151 + console.error(`Canvas surface ${targetId} failed to close while undocking`, error);
152 + }
153 + }
154 + const modalPromise = globalThis.ensureModalOpen?.(modalPath);
155 + if (modalPromise?.catch) {
156 + modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
157 + }
158 + return true;
159 + },
160 +
161 + async undockActiveSurface() {
162 + return await this.undockSurface(this.activeSurfaceId);
163 + },
164 +
165 + currentSurfaceCanUndock() {
166 + return Boolean(this.currentSurface()?.modalPath);
167 + },
168 +
169 + async toggle(surfaceId = "", payload = {}) {
170 + const targetId = surfaceId || this.activeSurfaceId || this.surfaces[0]?.id || "";
171 + if (this.isOpen && targetId === this.activeSurfaceId) {
172 + await this.close();
173 + return false;
174 + }
175 + return await this.open(targetId, payload);
176 + },
177 +
178 + setWidth(px, options = {}) {
179 + const { persist = true } = options;
180 + const max = this.maxWidth();
181 + const next = clamp(Number(px) || DEFAULT_WIDTH, MIN_WIDTH, max);
182 + this.width = next;
183 + this.applyLayoutState();
184 + if (persist) this.persist();
185 + },
186 +
187 + maxWidth() {
188 + return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(viewportWidth() * 0.58)));
189 + },
190 +
191 + defaultWidth() {
192 + return Math.min(DEFAULT_WIDTH, Math.floor(viewportWidth() * 0.45));
193 + },
194 +
195 + startResize(event) {
196 + if (this.isOverlayMode || this.isMobileMode || !this.isOpen) return;
197 + if (event.button !== 0) return;
198 + event.preventDefault();
199 +
200 + const onPointerMove = (moveEvent) => {
201 + const nextWidth = viewportWidth() - moveEvent.clientX;
202 + this.setWidth(nextWidth);
203 + };
204 + const onPointerUp = () => {
205 + globalThis.removeEventListener("pointermove", onPointerMove);
206 + globalThis.removeEventListener("pointerup", onPointerUp);
207 + document.body.classList.remove("right-canvas-resizing");
208 + this.persist();
209 + };
210 +
211 + document.body.classList.add("right-canvas-resizing");
212 + globalThis.addEventListener("pointermove", onPointerMove);
213 + globalThis.addEventListener("pointerup", onPointerUp);
214 + },
215 +
216 + persist() {
217 + try {
218 + localStorage.setItem(
219 + STORAGE_KEY,
220 + JSON.stringify({
221 + isOpen: this.isOpen,
222 + activeSurfaceId: this.activeSurfaceId,
223 + width: this.width,
224 + }),
225 + );
226 + } catch (error) {
227 + console.warn("Could not persist right canvas state", error);
228 + }
229 + },
230 +
231 + restore() {
232 + this.width = this.defaultWidth();
233 + try {
234 + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
235 + this.isOpen = Boolean(saved.isOpen);
236 + this.activeSurfaceId = String(saved.activeSurfaceId || "");
237 + if (saved.width) this.width = Number(saved.width);
238 + } catch (error) {
239 + console.warn("Could not restore right canvas state", error);
240 + }
241 + this.setWidth(this.width, { persist: false });
242 + },
243 +
244 + updateLayoutMode() {
245 + const width = viewportWidth();
246 + this.isOverlayMode = width < DESKTOP_BREAKPOINT;
247 + this.isMobileMode = width <= MOBILE_BREAKPOINT;
248 + },
249 +
250 + applyLayoutState() {
251 + this.updateLayoutMode();
252 + document.documentElement.style.setProperty("--right-canvas-width", `${this.width}px`);
253 + document.body.classList.toggle("right-canvas-open", this.isOpen);
254 + document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
255 + document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
256 + },
257 +
258 + widthStyle() {
259 + if (this.isMobileMode) return "";
260 + if (this.isOverlayMode) {
261 + return `width: min(${this.width}px, calc(100vw - 44px));`;
262 + }
263 + return `width: ${this.isOpen ? this.width : 52}px;`;
264 + },
265 +
266 + getSurface(id) {
267 + return this.surfaces.find((surface) => surface.id === id) || null;
268 + },
269 +
270 + currentSurface() {
271 + return this.getSurface(this.activeSurfaceId);
272 + },
273 +
274 + isSurfaceActive(id) {
275 + return this.activeSurfaceId === id;
276 + },
277 +
278 + activeTitle() {
279 + return this.currentSurface()?.title || "Canvas";
280 + },
281 +};
282 +
283 +export const store = createStore("rightCanvas", model);
webui/components/canvas/right-canvas.css new
+306
@@ -0,0 +1,306 @@
1 +.container > *,
2 +#right-panel {
3 + min-width: 0;
4 +}
5 +
6 +#right-panel {
7 + position: relative;
8 +}
9 +
10 +.container > x-component[path="canvas/right-canvas.html"],
11 +.container > x-component[path="canvas/right-canvas.html"] > div[x-data] {
12 + display: flex;
13 + flex: 0 0 auto;
14 + height: 100%;
15 + min-width: 0;
16 + min-height: 0;
17 +}
18 +
19 +body.right-canvas-resizing {
20 + cursor: col-resize;
21 + user-select: none;
22 +}
23 +
24 +.right-canvas {
25 + --right-canvas-chrome: color-mix(in srgb, var(--color-background) 94%, #000 6%);
26 + --right-canvas-surface: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);
27 + --right-canvas-border: color-mix(in srgb, var(--color-border) 70%, transparent);
28 + position: relative;
29 + z-index: 1200;
30 + display: flex;
31 + flex: 0 0 auto;
32 + height: 100%;
33 + min-width: 52px;
34 + max-width: min(900px, 58vw);
35 + overflow: hidden;
36 + border-left: 1px solid var(--right-canvas-border);
37 + background: var(--right-canvas-chrome);
38 + transition: width 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
39 +}
40 +
41 +.right-canvas.is-closed .right-canvas-shell {
42 + display: none;
43 +}
44 +
45 +.right-canvas-rail {
46 + display: flex;
47 + flex: 0 0 52px;
48 + flex-direction: column;
49 + align-items: center;
50 + gap: 7px;
51 + padding: 10px 7px;
52 + border-right: 1px solid var(--right-canvas-border);
53 + background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
54 +}
55 +
56 +.right-canvas-rail-button,
57 +.right-canvas-icon-button,
58 +.right-canvas-tab {
59 + appearance: none;
60 + border: 1px solid transparent;
61 + color: var(--color-text);
62 + font: inherit;
63 + cursor: pointer;
64 +}
65 +
66 +.right-canvas-rail-button,
67 +.right-canvas-icon-button {
68 + display: inline-flex;
69 + align-items: center;
70 + justify-content: center;
71 + width: 34px;
72 + height: 34px;
73 + min-width: 34px;
74 + min-height: 34px;
75 + padding: 0;
76 + border-radius: 7px;
77 + background: transparent;
78 + opacity: 0.76;
79 + transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
80 +}
81 +
82 +.right-canvas-rail-button:hover,
83 +.right-canvas-icon-button:hover,
84 +.right-canvas-rail-button.is-active {
85 + opacity: 1;
86 + border-color: color-mix(in srgb, var(--color-primary) 28%, var(--right-canvas-border));
87 + background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
88 +}
89 +
90 +.right-canvas-rail-button .material-symbols-outlined,
91 +.right-canvas-icon-button .material-symbols-outlined {
92 + font-size: 19px;
93 +}
94 +
95 +.right-canvas-shell {
96 + display: flex;
97 + flex: 1 1 auto;
98 + min-width: 0;
99 + min-height: 0;
100 + flex-direction: column;
101 +}
102 +
103 +.right-canvas-header {
104 + display: grid;
105 + grid-template-columns: minmax(0, 1fr) auto;
106 + align-items: center;
107 + gap: 8px;
108 + min-height: 45px;
109 + padding: 7px 8px 0 10px;
110 + border-bottom: 1px solid var(--right-canvas-border);
111 + background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
112 +}
113 +
114 +.right-canvas-tabs {
115 + display: flex;
116 + align-items: end;
117 + gap: 4px;
118 + min-width: 0;
119 + overflow-x: auto;
120 + overflow-y: hidden;
121 + scrollbar-width: thin;
122 +}
123 +
124 +.right-canvas-tabs::-webkit-scrollbar {
125 + height: 4px;
126 +}
127 +
128 +.right-canvas-tabs::-webkit-scrollbar-track {
129 + background: transparent;
130 +}
131 +
132 +.right-canvas-tabs::-webkit-scrollbar-thumb {
133 + background: color-mix(in srgb, var(--color-border) 76%, transparent);
134 + border-radius: 999px;
135 +}
136 +
137 +.right-canvas-tab {
138 + display: inline-flex;
139 + flex: 0 1 auto;
140 + align-items: center;
141 + gap: 7px;
142 + min-width: 0;
143 + height: 34px;
144 + padding: 0 10px;
145 + border-radius: 7px 7px 0 0;
146 + background: transparent;
147 + opacity: 0.7;
148 + white-space: nowrap;
149 + transition: border-color 0.16s ease, background-color 0.16s ease, opacity 0.16s ease;
150 +}
151 +
152 +.right-canvas-tab:hover,
153 +.right-canvas-tab.is-active {
154 + opacity: 1;
155 + border-color: var(--right-canvas-border);
156 + background: color-mix(in srgb, var(--color-panel) 72%, transparent);
157 +}
158 +
159 +.right-canvas-tab .material-symbols-outlined {
160 + flex: 0 0 auto;
161 + font-size: 18px;
162 +}
163 +
164 +.right-canvas-tab-label {
165 + min-width: 0;
166 + overflow: hidden;
167 + text-overflow: ellipsis;
168 + font-size: 0.84rem;
169 + font-weight: 650;
170 +}
171 +
172 +.right-canvas-toolbar {
173 + display: flex;
174 + align-items: center;
175 + gap: 5px;
176 + padding-bottom: 6px;
177 +}
178 +
179 +.right-canvas-panels {
180 + position: relative;
181 + display: flex;
182 + flex: 1 1 auto;
183 + min-width: 0;
184 + min-height: 0;
185 + overflow: hidden;
186 + background: var(--right-canvas-surface);
187 +}
188 +
189 +.right-canvas-panels > x-extension {
190 + display: contents;
191 +}
192 +
193 +.right-canvas-panels > x-extension > x-component {
194 + display: contents;
195 +}
196 +
197 +.right-canvas-surface-panel {
198 + display: flex;
199 + flex: 1 1 auto;
200 + width: 100%;
201 + min-width: 0;
202 + min-height: 0;
203 + overflow: hidden;
204 +}
205 +
206 +.right-canvas-surface-panel > x-component,
207 +.right-canvas-surface-panel > x-component > div[x-data] {
208 + display: flex;
209 + flex: 1 1 auto;
210 + width: 100%;
211 + height: 100%;
212 + min-width: 0;
213 + min-height: 0;
214 +}
215 +
216 +.right-canvas-empty-state {
217 + display: grid;
218 + flex: 1 1 auto;
219 + place-items: center;
220 + align-content: center;
221 + gap: 8px;
222 + color: color-mix(in srgb, var(--color-text) 60%, transparent);
223 +}
224 +
225 +.right-canvas-empty-state .material-symbols-outlined {
226 + font-size: 28px;
227 +}
228 +
229 +.right-canvas-resize-handle {
230 + position: absolute;
231 + top: 0;
232 + bottom: 0;
233 + left: -3px;
234 + z-index: 3;
235 + width: 7px;
236 + cursor: col-resize;
237 +}
238 +
239 +body.right-canvas-overlay-mode .right-canvas {
240 + position: fixed;
241 + top: 0;
242 + right: 0;
243 + bottom: 0;
244 + height: 100%;
245 + max-width: calc(100vw - 44px);
246 + box-shadow: -16px 0 38px rgba(0, 0, 0, 0.34);
247 + transform: translateX(100%);
248 +}
249 +
250 +body.right-canvas-overlay-mode .right-canvas.is-open {
251 + transform: translateX(0);
252 +}
253 +
254 +body.right-canvas-overlay-mode .right-canvas.is-closed {
255 + overflow: visible;
256 +}
257 +
258 +body.right-canvas-overlay-mode .right-canvas.is-closed .right-canvas-rail {
259 + position: absolute;
260 + right: 100%;
261 + top: 50%;
262 + height: auto;
263 + max-height: 70vh;
264 + transform: translateY(-50%);
265 + border: 1px solid var(--right-canvas-border);
266 + border-right: 0;
267 + border-radius: 8px 0 0 8px;
268 + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.24);
269 +}
270 +
271 +body.right-canvas-overlay-mode .right-canvas-resize-handle {
272 + display: none;
273 +}
274 +
275 +body.right-canvas-mobile-mode .right-canvas {
276 + left: 0;
277 + width: 100vw !important;
278 + max-width: 100vw;
279 + min-width: 100vw;
280 + border-left: 0;
281 +}
282 +
283 +body.right-canvas-mobile-mode .right-canvas.is-closed {
284 + transform: translateX(100%);
285 +}
286 +
287 +body.right-canvas-mobile-mode .right-canvas-rail {
288 + display: none;
289 +}
290 +
291 +body.right-canvas-mobile-mode .right-canvas-header {
292 + min-height: 48px;
293 + padding: 7px 8px 0;
294 +}
295 +
296 +body.right-canvas-mobile-mode .right-canvas-tab {
297 + min-width: 42px;
298 + justify-content: center;
299 + padding: 0 9px;
300 +}
301 +
302 +@media (max-width: 480px) {
303 + .right-canvas-tab-label {
304 + display: none;
305 + }
306 +}
webui/components/canvas/right-canvas.html new
+106
@@ -0,0 +1,106 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/components/canvas/right-canvas-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div x-data>
9 + <template x-if="$store.rightCanvas">
10 + <aside
11 + id="right-canvas"
12 + class="right-canvas"
13 + :class="{
14 + 'is-open': $store.rightCanvas.isOpen,
15 + 'is-closed': !$store.rightCanvas.isOpen,
16 + 'is-overlay': $store.rightCanvas.isOverlayMode,
17 + 'is-mobile': $store.rightCanvas.isMobileMode
18 + }"
19 + :style="$store.rightCanvas.widthStyle()"
20 + x-init="$store.rightCanvas.init($el)"
21 + x-effect="$store.rightCanvas.applyLayoutState()"
22 + aria-label="Universal Canvas"
23 + >
24 + <div class="right-canvas-resize-handle" title="Resize canvas" @pointerdown="$store.rightCanvas.startResize($event)"></div>
25 +
26 + <nav class="right-canvas-rail" aria-label="Canvas surfaces">
27 + <template x-for="surface in $store.rightCanvas.surfaces" :key="surface.id">
28 + <button
29 + type="button"
30 + class="right-canvas-rail-button"
31 + :class="{ 'is-active': $store.rightCanvas.isSurfaceActive(surface.id) && $store.rightCanvas.isOpen }"
32 + :title="surface.title"
33 + :aria-label="surface.title"
34 + @click="$store.rightCanvas.toggle(surface.id)"
35 + >
36 + <span class="material-symbols-outlined" x-text="surface.icon"></span>
37 + </button>
38 + </template>
39 + </nav>
40 +
41 + <section class="right-canvas-shell">
42 + <x-extension id="right-canvas-shell-start"></x-extension>
43 +
44 + <header class="right-canvas-header">
45 + <div class="right-canvas-tabs" role="tablist" aria-label="Canvas tabs">
46 + <x-extension id="right-canvas-tabs-start"></x-extension>
47 + <template x-for="surface in $store.rightCanvas.surfaces" :key="surface.id">
48 + <button
49 + type="button"
50 + class="right-canvas-tab"
51 + role="tab"
52 + :aria-selected="$store.rightCanvas.isSurfaceActive(surface.id).toString()"
53 + :title="surface.title"
54 + :class="{ 'is-active': $store.rightCanvas.isSurfaceActive(surface.id) }"
55 + @click="$store.rightCanvas.open(surface.id)"
56 + >
57 + <span class="material-symbols-outlined" aria-hidden="true" x-text="surface.icon"></span>
58 + <span class="right-canvas-tab-label" x-text="surface.title"></span>
59 + </button>
60 + </template>
61 + <x-extension id="right-canvas-tabs-end"></x-extension>
62 + </div>
63 +
64 + <div class="right-canvas-toolbar" aria-label="Canvas toolbar">
65 + <x-extension id="right-canvas-toolbar-start"></x-extension>
66 + <button
67 + type="button"
68 + class="right-canvas-icon-button"
69 + title="Open as window"
70 + aria-label="Open as window"
71 + x-show="$store.rightCanvas.currentSurfaceCanUndock()"
72 + @click="$store.rightCanvas.undockActiveSurface()"
73 + >
74 + <span class="material-symbols-outlined">open_in_new</span>
75 + </button>
76 + <button
77 + type="button"
78 + class="right-canvas-icon-button"
79 + title="Close canvas"
80 + aria-label="Close canvas"
81 + @click="$store.rightCanvas.close()"
82 + >
83 + <span class="material-symbols-outlined">close</span>
84 + </button>
85 + <x-extension id="right-canvas-toolbar-end"></x-extension>
86 + </div>
87 + </header>
88 +
89 + <main class="right-canvas-panels">
90 + <x-extension id="right-canvas-panels"></x-extension>
91 + <template x-if="$store.rightCanvas.surfaces.length === 0">
92 + <div class="right-canvas-empty-state">
93 + <x-extension id="right-canvas-empty-state"></x-extension>
94 + <span class="material-symbols-outlined">space_dashboard</span>
95 + <span>Canvas</span>
96 + </div>
97 + </template>
98 + </main>
99 +
100 + <x-extension id="right-canvas-shell-end"></x-extension>
101 + </section>
102 + </aside>
103 + </template>
104 + </div>
105 +</body>
106 +</html>
webui/css/modals.css
+34 -1
@@ -78,9 +78,10 @@ some classes like modal-header are shared between the old and the new system */
78 /* Modal Header */
79 .modal-header {
80 display: grid;
81 - grid-template-columns: 40fr 0.5fr;
81 + grid-template-columns: minmax(0, 1fr) auto auto;
82 align-items: center;
83 justify-content: space-between;
84 + gap: 0.5rem;
85 padding: 0.5rem 1.5rem 0.5rem 2rem;
86 background-color: var(--color-background);
87 color: var(--color-primary);
@@ -88,6 +89,10 @@ some classes like modal-header are shared between the old and the new system */
89 }
90 .modal-header h2 {
91 margin: 0;
92 + min-width: 0;
93 + overflow: hidden;
94 + text-overflow: ellipsis;
95 + white-space: nowrap;
96 }
97
98 /* Modal Subheader */
@@ -114,6 +119,34 @@ some classes like modal-header are shared between the old and the new system */
119 opacity: 1;
120 }
121
122 +.modal-dock-button {
123 + display: inline-flex;
124 + align-items: center;
125 + justify-content: center;
126 + width: 34px;
127 + height: 34px;
128 + min-width: 34px;
129 + min-height: 34px;
130 + padding: 0;
131 + border: 1px solid transparent;
132 + border-radius: 7px;
133 + background: transparent;
134 + color: var(--color-text);
135 + cursor: pointer;
136 + opacity: 0.72;
137 + transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
138 +}
139 +
140 +.modal-dock-button:hover {
141 + opacity: 1;
142 + border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
143 + background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
144 +}
145 +
146 +.modal-dock-button .material-symbols-outlined {
147 + font-size: 19px;
148 +}
149 +
150 /* Modal Description */
151 .modal-description {
152 padding: 0.8rem 1rem 0 1rem;
webui/index.css
+9 -4
@@ -137,6 +137,7 @@ html {
137 body {
138 overscroll-behavior: none;
139 -webkit-overscroll-behavior: none;
140 + overflow-x: hidden;
141 }
142
143 x-extension:not(:empty) {
@@ -393,6 +394,8 @@ img {
394 display: -webkit-flex;
395 display: flex;
396 height: 100%;
397 + min-width: 0;
398 + overflow: hidden;
399 }
400
401 .panel {
@@ -408,9 +411,10 @@ img {
411 #right-panel {
412 display: -webkit-flex;
413 display: flex;
411 - width: 100%;
414 + width: auto;
415 + min-width: 0;
416 flex-direction: column;
413 - flex-grow: 1;
417 + flex: 1 1 0;
418 -webkit-transition: margin-left var(--transition-speed) ease-in-out;
419 transition: margin-left var(--transition-speed) ease-in-out;
420 }
@@ -477,7 +481,8 @@ div#right-panel::-webkit-scrollbar-thumb:hover {
481
482 #time-date-container {
483 z-index: 1000;
480 - position: fixed;
484 + position: absolute;
485 + top: 0;
486 right: var(--spacing-md);
487 display: flex;
488 align-items: center;
@@ -1684,4 +1689,4 @@ body *::-webkit-scrollbar-thumb:active {
1689
1690 .light-mode *::-webkit-scrollbar-thumb:active {
1691 background-color: #8a8a8a;
1687 -}
\ No newline at end of file
1692 +}
webui/index.html
+3 -1
@@ -19,6 +19,7 @@
19 <link rel="stylesheet" href="css/notification.css">
20 <link rel="stylesheet" href="css/buttons.css">
21 <link rel="stylesheet" href="css/tables.css">
22 + <link rel="stylesheet" href="components/canvas/right-canvas.css">
23
24 <!-- Flatpickr for datetime picker -->
25 <link rel="stylesheet" href="vendor/flatpickr/flatpickr.min.css">
@@ -139,7 +140,8 @@
140 <x-component path="chat/input/chat-bar.html"></x-component>
141 </div>
142 </div>
142 - </div>
143 + <x-component path="canvas/right-canvas.html"></x-component>
144 + </div>
145
146 <!-- Drag and Drop Overlay Component -->
147 <x-component path="chat/attachments/dragDropOverlay.html"></x-component>
webui/js/modals.js
+42
@@ -56,6 +56,8 @@ function modalSuppressesBackdrop(modal) {
56 const path = String(modal?.path || "");
57 return path === "/plugins/_browser/webui/main.html"
58 || path === "plugins/_browser/webui/main.html"
59 + || path === "/plugins/_office/webui/main.html"
60 + || path === "plugins/_office/webui/main.html"
61 || modal?.element?.classList?.contains("modal-floating")
62 || modal?.element?.classList?.contains("modal-no-backdrop")
63 || modal?.inner?.classList?.contains("modal-no-backdrop");
@@ -151,6 +153,7 @@ function createModalElement(path) {
153 path: path,
154 element: newModal,
155 title: newModal.querySelector(".modal-title"),
156 + header: newModal.querySelector(".modal-header"),
157 body: newModal.querySelector(".modal-bd"),
158 close: close_button,
159 footerSlot: newModal.querySelector(".modal-footer-slot"),
@@ -162,6 +165,43 @@ function createModalElement(path) {
165 };
166 }
167
168 +function getDockMetadata(doc, modalPath) {
169 + const htmlDataset = doc?.documentElement?.dataset || {};
170 + const bodyDataset = doc?.body?.dataset || {};
171 + const surfaceId = htmlDataset.canvasSurface || bodyDataset.canvasSurface || "";
172 + if (!surfaceId) return null;
173 + return {
174 + surfaceId,
175 + modalPath: htmlDataset.canvasModalPath || bodyDataset.canvasModalPath || modalPath,
176 + title: htmlDataset.canvasDockTitle || bodyDataset.canvasDockTitle || "Open in canvas",
177 + icon: htmlDataset.canvasDockIcon || bodyDataset.canvasDockIcon || "dock_to_right",
178 + };
179 +}
180 +
181 +function configureModalDockButton(modal, doc) {
182 + const metadata = getDockMetadata(doc, modal.path);
183 + if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) {
184 + return;
185 + }
186 +
187 + const button = document.createElement("button");
188 + button.type = "button";
189 + button.className = "modal-dock-button";
190 + button.title = metadata.title;
191 + button.setAttribute("aria-label", metadata.title);
192 + button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
193 + button.addEventListener("click", async () => {
194 + const canvas = globalThis.Alpine?.store?.("rightCanvas")
195 + || (await import("/components/canvas/right-canvas-store.js")).store;
196 + await canvas?.dockSurface?.(metadata.surfaceId, {
197 + modalPath: metadata.modalPath,
198 + source: "modal",
199 + });
200 + });
201 +
202 + modal.close?.insertAdjacentElement("beforebegin", button);
203 +}
204 +
205 // Function to open modal with content from URL
206 export async function openModal(modalPath, beforeClose = null) {
207 const openCtx = { modalPath, modal: null, cancel: false };
@@ -208,6 +248,8 @@ export async function openModal(modalPath, beforeClose = null) {
248 if (doc.body && doc.body.classList) {
249 modal.body.classList.add(...doc.body.classList);
250 }
251 + configureModalDockButton(modal, doc);
252 + updateModalZIndexes();
253
254 // Some modals have a footer. Check if it exists and move it to footer slot
255 // Use requestAnimationFrame to let Alpine mount the component first