Problem: the hexagonal refactor churns the backend tree for nine more phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper, compose/web image) must move to its own repo first so doc/layout rewrites land once on a backend-only tree. Change: - New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/ (updateURL repointed to oikos-web releases), compose/, own CI (web + desktop jobs), own deploy script (CI-green gate, TOCTOU guard, version-tagged images, prune-to-3), own webhook receiver on :9798 + launchd unit, own compose project publishing the same 8091:80. - Cutover executed on mac-mini in order: oikos stack's web service stopped+removed, oikos-web project brought up on 8091; outer Caddy untouched (targets the published port) — serving + Authentik flow + /wails 404 quirk verified post-cutover. - Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install targets, the compose web service, oikos-web from deploy.sh's fallback prune list; wails + go-keyring dropped from go.mod, vendor synced. - README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now point at the new repo; mbse + mascot design docs carry a path note. Risk: production SPA serving depends on the new pipeline now; rollback is versioned-image re-up of the old web service from a pre-split checkout (port 8091). Desktop builds installed before the split still check dtoro/oikos releases — one manual reinstall, noted in the oikos-web release notes. Verification: go vet, make test (race), make generate-check, golangci (no new findings; baseline down 400→365); post-cutover curls — localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
1722 lines
60 KiB
JavaScript
1722 lines
60 KiB
JavaScript
// src/core/emitter.ts
|
|
function createEmitter() {
|
|
const listeners = /* @__PURE__ */ new Map();
|
|
return {
|
|
on(event, listener) {
|
|
let set = listeners.get(event);
|
|
if (!set) {
|
|
set = /* @__PURE__ */ new Set();
|
|
listeners.set(event, set);
|
|
}
|
|
set.add(listener);
|
|
return () => {
|
|
set.delete(listener);
|
|
};
|
|
},
|
|
emit(event, payload) {
|
|
const set = listeners.get(event);
|
|
if (!set) return;
|
|
for (const listener of [...set]) {
|
|
listener(payload);
|
|
}
|
|
},
|
|
clear() {
|
|
listeners.clear();
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/core/geometry.ts
|
|
function clamp(value, min, max) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
function clampSize(size, min, max) {
|
|
return {
|
|
width: clamp(size.width, min.width, max ? max.width : Number.POSITIVE_INFINITY),
|
|
height: clamp(size.height, min.height, max ? max.height : Number.POSITIVE_INFINITY)
|
|
};
|
|
}
|
|
function clampToViewport(bounds, viewport, minVisible) {
|
|
if (viewport.width <= 0 || viewport.height <= 0) return bounds;
|
|
const x = clamp(bounds.x, minVisible - bounds.width, viewport.width - minVisible);
|
|
const y = clamp(bounds.y, 0, Math.max(0, viewport.height - minVisible));
|
|
return x === bounds.x && y === bounds.y ? bounds : { ...bounds, x, y };
|
|
}
|
|
function boundsEqual(a, b) {
|
|
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
}
|
|
function zoneBounds(zone, viewport) {
|
|
const w = viewport.width;
|
|
const h = viewport.height;
|
|
const halfW = Math.round(w / 2);
|
|
const halfH = Math.round(h / 2);
|
|
switch (zone) {
|
|
case "left":
|
|
return { x: 0, y: 0, width: halfW, height: h };
|
|
case "right":
|
|
return { x: halfW, y: 0, width: w - halfW, height: h };
|
|
case "top":
|
|
return { x: 0, y: 0, width: w, height: halfH };
|
|
case "bottom":
|
|
return { x: 0, y: halfH, width: w, height: h - halfH };
|
|
case "top-left":
|
|
return { x: 0, y: 0, width: halfW, height: halfH };
|
|
case "top-right":
|
|
return { x: halfW, y: 0, width: w - halfW, height: halfH };
|
|
case "bottom-left":
|
|
return { x: 0, y: halfH, width: halfW, height: h - halfH };
|
|
case "bottom-right":
|
|
return { x: halfW, y: halfH, width: w - halfW, height: h - halfH };
|
|
}
|
|
}
|
|
function detectSnapZone(x, y, viewport, options = {}) {
|
|
const threshold = options.threshold ?? 12;
|
|
const cornerSize = options.cornerSize ?? 64;
|
|
const w = viewport.width;
|
|
const h = viewport.height;
|
|
if (w <= 0 || h <= 0) return null;
|
|
const nearLeft = x <= threshold;
|
|
const nearRight = x >= w - threshold;
|
|
const nearTop = y <= threshold;
|
|
const nearBottom = y >= h - threshold;
|
|
if (nearLeft || nearRight) {
|
|
const side = nearLeft ? "left" : "right";
|
|
if (y <= cornerSize) return `top-${side}`;
|
|
if (y >= h - cornerSize) return `bottom-${side}`;
|
|
return side;
|
|
}
|
|
if (nearTop) {
|
|
if (x <= cornerSize) return "top-left";
|
|
if (x >= w - cornerSize) return "top-right";
|
|
return "top";
|
|
}
|
|
if (nearBottom) {
|
|
if (x <= cornerSize) return "bottom-left";
|
|
if (x >= w - cornerSize) return "bottom-right";
|
|
return "bottom";
|
|
}
|
|
return null;
|
|
}
|
|
function nearestEdge(low, high, targetLow, targetHigh) {
|
|
return [targetLow - low, targetHigh - low, targetLow - high, targetHigh - high];
|
|
}
|
|
function magnetize(bounds, targets, threshold) {
|
|
const result = { x: bounds.x, y: bounds.y, snappedX: false, snappedY: false };
|
|
if (threshold <= 0 || targets.length === 0) return result;
|
|
let bestX = threshold + 1;
|
|
let bestY = threshold + 1;
|
|
for (const target of targets) {
|
|
for (const delta of nearestEdge(
|
|
bounds.x,
|
|
bounds.x + bounds.width,
|
|
target.x,
|
|
target.x + target.width
|
|
)) {
|
|
if (Math.abs(delta) <= threshold && Math.abs(delta) < Math.abs(bestX)) bestX = delta;
|
|
}
|
|
for (const delta of nearestEdge(
|
|
bounds.y,
|
|
bounds.y + bounds.height,
|
|
target.y,
|
|
target.y + target.height
|
|
)) {
|
|
if (Math.abs(delta) <= threshold && Math.abs(delta) < Math.abs(bestY)) bestY = delta;
|
|
}
|
|
}
|
|
if (Math.abs(bestX) <= threshold) {
|
|
result.x = bounds.x + bestX;
|
|
result.snappedX = true;
|
|
}
|
|
if (Math.abs(bestY) <= threshold) {
|
|
result.y = bounds.y + bestY;
|
|
result.snappedY = true;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// src/core/manager.ts
|
|
var LAYER_RANK = { normal: 0, floating: 1, modal: 2 };
|
|
var STAGES = ["normal", "minimized", "maximized", "snapped"];
|
|
var LAYERS = ["normal", "floating", "modal"];
|
|
var ZONES = [
|
|
"left",
|
|
"right",
|
|
"top",
|
|
"bottom",
|
|
"top-left",
|
|
"top-right",
|
|
"bottom-left",
|
|
"bottom-right"
|
|
];
|
|
function createWindowManager(options = {}) {
|
|
const emitter = createEmitter();
|
|
const keepInViewport = options.keepInViewport ?? true;
|
|
const minVisible = options.minVisible ?? 48;
|
|
const defaultSize = options.defaultSize ?? { width: 480, height: 320 };
|
|
const cascadeOffset = options.cascadeOffset ?? 32;
|
|
const cascadeOrigin = options.cascadeOrigin ?? { x: 32, y: 32 };
|
|
const idPrefix = options.idPrefix ?? "wm";
|
|
const historyLimit = options.historyLimit ?? 50;
|
|
let windows = {};
|
|
let order = [];
|
|
let focusedId = null;
|
|
let viewport = options.viewport ?? { width: 0, height: 0 };
|
|
let seq = 0;
|
|
let idCounter = 0;
|
|
let cascadeIndex = 0;
|
|
let snapshot = null;
|
|
let batchDepth = 0;
|
|
let batchDirty = false;
|
|
const pendingEvents = [];
|
|
const past = [];
|
|
let future = [];
|
|
let interactionDepth = 0;
|
|
let interactionRecorded = false;
|
|
let skipNextHistory = false;
|
|
let pendingHistory = null;
|
|
const layouts = /* @__PURE__ */ new Map();
|
|
function getState() {
|
|
if (!snapshot) {
|
|
snapshot = { windows, order, focusedId, viewport };
|
|
}
|
|
return snapshot;
|
|
}
|
|
function commit() {
|
|
snapshot = null;
|
|
batchDirty = true;
|
|
}
|
|
function flushEvents() {
|
|
while (pendingEvents.length > 0) {
|
|
for (const emit of pendingEvents.splice(0)) emit();
|
|
}
|
|
}
|
|
function queueEvent(emit) {
|
|
pendingEvents.push(emit);
|
|
}
|
|
function setWindow(next) {
|
|
windows = { ...windows, [next.id]: next };
|
|
}
|
|
function removeWindow(id) {
|
|
const { [id]: _removed, ...rest } = windows;
|
|
windows = rest;
|
|
order = order.filter((entry) => entry !== id);
|
|
}
|
|
function layerRankOf(id) {
|
|
return LAYER_RANK[windows[id].layer];
|
|
}
|
|
function sortByLayer(ids) {
|
|
return ids.map((id, index) => ({ id, index, rank: layerRankOf(id) })).sort((a, b) => a.rank !== b.rank ? a.rank - b.rank : a.index - b.index).map((entry) => entry.id);
|
|
}
|
|
function raise(id) {
|
|
const win = windows[id];
|
|
const without = order.filter((entry) => entry !== id);
|
|
const rank = LAYER_RANK[win.layer];
|
|
let insertAt = without.length;
|
|
for (let i = without.length - 1; i >= 0; i -= 1) {
|
|
if (layerRankOf(without[i]) > rank) insertAt = i;
|
|
else break;
|
|
}
|
|
const next = [...without.slice(0, insertAt), id, ...without.slice(insertAt)];
|
|
const changed = next.length !== order.length || next.some((entry, i) => entry !== order[i]);
|
|
order = next;
|
|
return changed;
|
|
}
|
|
function topModalId() {
|
|
for (let i = order.length - 1; i >= 0; i -= 1) {
|
|
const win = windows[order[i]];
|
|
if (win.layer === "modal" && win.stage !== "minimized") return win.id;
|
|
}
|
|
return null;
|
|
}
|
|
function focusTargets() {
|
|
const modal = topModalId();
|
|
return order.filter((id) => {
|
|
const win = windows[id];
|
|
if (win.stage === "minimized") return false;
|
|
if (modal && win.layer !== "modal") return false;
|
|
return true;
|
|
});
|
|
}
|
|
function focusTop() {
|
|
const targets = focusTargets();
|
|
focusedId = targets.length > 0 ? targets[targets.length - 1] : null;
|
|
}
|
|
function emitFocus(win, previous) {
|
|
queueEvent(() => emitter.emit("focus", { window: win, previous }));
|
|
}
|
|
function normalizeSize(size, win) {
|
|
return clampSize(size, win.minSize, win.maxSize);
|
|
}
|
|
function positionForOpen(init) {
|
|
if (init.x !== void 0 && init.y !== void 0) return { x: init.x, y: init.y };
|
|
const offset = cascadeIndex % 10 * cascadeOffset;
|
|
cascadeIndex += 1;
|
|
const base = { x: cascadeOrigin.x + offset, y: cascadeOrigin.y + offset };
|
|
return { x: init.x ?? base.x, y: init.y ?? base.y };
|
|
}
|
|
function open(init = {}) {
|
|
let id = init.id;
|
|
if (id === void 0) {
|
|
do {
|
|
id = `${idPrefix}-${++idCounter}`;
|
|
} while (windows[id]);
|
|
}
|
|
if (windows[id]) throw new Error(`wmkit: window id "${id}" already exists`);
|
|
const minSize = { width: init.minWidth ?? 160, height: init.minHeight ?? 100 };
|
|
const maxSize = init.maxWidth !== void 0 || init.maxHeight !== void 0 ? {
|
|
width: init.maxWidth ?? Number.POSITIVE_INFINITY,
|
|
height: init.maxHeight ?? Number.POSITIVE_INFINITY
|
|
} : null;
|
|
const size = clampSize(
|
|
{ width: init.width ?? defaultSize.width, height: init.height ?? defaultSize.height },
|
|
minSize,
|
|
maxSize
|
|
);
|
|
const position = positionForOpen(init);
|
|
let bounds = { ...position, ...size };
|
|
if (keepInViewport) bounds = clampToViewport(bounds, viewport, minVisible);
|
|
const requestedStage = init.stage ?? "normal";
|
|
const stage = requestedStage === "snapped" ? "normal" : requestedStage;
|
|
let win = {
|
|
id,
|
|
title: init.title ?? "Window",
|
|
bounds,
|
|
restoreBounds: null,
|
|
restoreStage: null,
|
|
stage: "normal",
|
|
snapZone: null,
|
|
layer: init.layer ?? "normal",
|
|
minSize,
|
|
maxSize,
|
|
openedSeq: ++seq,
|
|
draggable: init.draggable ?? true,
|
|
resizable: init.resizable ?? true,
|
|
closable: init.closable ?? true,
|
|
minimizable: init.minimizable ?? true,
|
|
maximizable: init.maximizable ?? true,
|
|
snappable: init.snappable ?? true,
|
|
meta: init.meta ?? {}
|
|
};
|
|
if (stage === "maximized") {
|
|
win = { ...win, stage, restoreBounds: bounds, bounds: fullBounds() };
|
|
} else if (stage === "minimized") {
|
|
win = { ...win, stage, restoreStage: "normal" };
|
|
}
|
|
setWindow(win);
|
|
raise(id);
|
|
let focusPayload = null;
|
|
if (stage !== "minimized") {
|
|
const previous = focusedId;
|
|
const modal = topModalId();
|
|
if (!modal || win.layer === "modal") {
|
|
focusedId = id;
|
|
focusPayload = { window: win, previous };
|
|
} else {
|
|
focusTop();
|
|
}
|
|
}
|
|
queueEvent(() => emitter.emit("open", { window: win }));
|
|
if (focusPayload) {
|
|
const payload = focusPayload;
|
|
queueEvent(() => emitter.emit("focus", payload));
|
|
}
|
|
queueEvent(() => emitter.emit("order", { order }));
|
|
commit();
|
|
return win;
|
|
}
|
|
function close(id) {
|
|
const win = windows[id];
|
|
if (!win) return false;
|
|
removeWindow(id);
|
|
queueEvent(() => emitter.emit("close", { window: win }));
|
|
if (focusedId === id) {
|
|
focusTop();
|
|
if (focusedId) emitFocus(windows[focusedId], id);
|
|
}
|
|
queueEvent(() => emitter.emit("order", { order }));
|
|
commit();
|
|
return true;
|
|
}
|
|
function closeAll() {
|
|
for (const id of [...order]) close(id);
|
|
}
|
|
function focus(id) {
|
|
const win = windows[id];
|
|
if (!win) return false;
|
|
const modal = topModalId();
|
|
if (modal && modal !== id && win.layer !== "modal") {
|
|
const modalWin = windows[modal];
|
|
if (modalWin) queueEvent(() => emitter.emit("modalblocked", { window: modalWin }));
|
|
commit();
|
|
return false;
|
|
}
|
|
if (win.stage === "minimized") {
|
|
restore(id);
|
|
return focusedId === id;
|
|
}
|
|
const previous = focusedId;
|
|
const raised = raise(id);
|
|
const changed = focusedId !== id || raised;
|
|
focusedId = id;
|
|
if (changed) {
|
|
emitFocus(win, previous);
|
|
if (raised) queueEvent(() => emitter.emit("order", { order }));
|
|
commit();
|
|
}
|
|
return true;
|
|
}
|
|
function blur() {
|
|
if (focusedId === null) return;
|
|
focusedId = null;
|
|
commit();
|
|
}
|
|
function cycleFocus(direction = 1) {
|
|
const targets = focusTargets();
|
|
if (targets.length === 0) return null;
|
|
const current = focusedId ? targets.indexOf(focusedId) : -1;
|
|
const nextIndex = current === -1 ? direction === 1 ? 0 : targets.length - 1 : (current + direction + targets.length) % targets.length;
|
|
const id = targets[nextIndex];
|
|
focus(id);
|
|
return id;
|
|
}
|
|
function fullBounds() {
|
|
return { x: 0, y: 0, width: viewport.width, height: viewport.height };
|
|
}
|
|
function applyStage(id, build) {
|
|
const win = windows[id];
|
|
if (!win) return false;
|
|
const next = build(win);
|
|
if (!next) return false;
|
|
setWindow(next);
|
|
queueEvent(() => emitter.emit("stage", { window: next, previous: win.stage }));
|
|
commit();
|
|
return true;
|
|
}
|
|
function minimize(id) {
|
|
const result = applyStage(id, (win) => {
|
|
if (win.stage === "minimized") return null;
|
|
return { ...win, stage: "minimized", restoreStage: win.stage };
|
|
});
|
|
if (result && focusedId === id) {
|
|
const previous = focusedId;
|
|
focusTop();
|
|
if (focusedId) emitFocus(windows[focusedId], previous);
|
|
commit();
|
|
}
|
|
return result;
|
|
}
|
|
function maximize(id) {
|
|
const result = applyStage(id, (win) => {
|
|
if (win.stage === "maximized") return null;
|
|
const restoreBounds = win.stage === "normal" ? win.bounds : win.restoreBounds;
|
|
return {
|
|
...win,
|
|
stage: "maximized",
|
|
snapZone: null,
|
|
restoreBounds,
|
|
restoreStage: null,
|
|
bounds: fullBounds()
|
|
};
|
|
});
|
|
if (result) focus(id);
|
|
return result;
|
|
}
|
|
function toggleMaximize(id) {
|
|
const win = windows[id];
|
|
if (!win) return false;
|
|
return win.stage === "maximized" ? restore(id) : maximize(id);
|
|
}
|
|
function restore(id) {
|
|
const result = applyStage(id, (win) => {
|
|
if (win.stage === "minimized") {
|
|
const target = win.restoreStage ?? "normal";
|
|
if (target === "maximized") {
|
|
return { ...win, stage: "maximized", restoreStage: null, bounds: fullBounds() };
|
|
}
|
|
if (target === "snapped" && win.snapZone) {
|
|
return {
|
|
...win,
|
|
stage: "snapped",
|
|
restoreStage: null,
|
|
bounds: zoneBounds(win.snapZone, viewport)
|
|
};
|
|
}
|
|
return { ...win, stage: "normal", restoreStage: null };
|
|
}
|
|
if (win.stage === "normal") return null;
|
|
const bounds = win.restoreBounds ?? win.bounds;
|
|
return {
|
|
...win,
|
|
stage: "normal",
|
|
snapZone: null,
|
|
restoreBounds: null,
|
|
restoreStage: null,
|
|
bounds: keepInViewport ? clampToViewport(bounds, viewport, minVisible) : bounds
|
|
};
|
|
});
|
|
if (result) focus(id);
|
|
return result;
|
|
}
|
|
function restoreTo(id, bounds) {
|
|
const result = applyStage(id, (win) => {
|
|
const size = normalizeSize(bounds, win);
|
|
const next = { x: bounds.x, y: bounds.y, ...size };
|
|
return {
|
|
...win,
|
|
stage: "normal",
|
|
snapZone: null,
|
|
restoreBounds: null,
|
|
restoreStage: null,
|
|
bounds: keepInViewport ? clampToViewport(next, viewport, minVisible) : next
|
|
};
|
|
});
|
|
if (result) focus(id);
|
|
return result;
|
|
}
|
|
function snap(id, zone) {
|
|
const result = applyStage(id, (win) => {
|
|
const restoreBounds = win.stage === "normal" ? win.bounds : win.restoreBounds;
|
|
return {
|
|
...win,
|
|
stage: "snapped",
|
|
snapZone: zone,
|
|
restoreBounds,
|
|
restoreStage: null,
|
|
bounds: zoneBounds(zone, viewport)
|
|
};
|
|
});
|
|
if (result) focus(id);
|
|
return result;
|
|
}
|
|
function move(id, x, y) {
|
|
const win = windows[id];
|
|
if (win?.stage !== "normal") return false;
|
|
let bounds = { ...win.bounds, x, y };
|
|
if (keepInViewport) bounds = clampToViewport(bounds, viewport, minVisible);
|
|
if (boundsEqual(bounds, win.bounds)) return true;
|
|
const next = { ...win, bounds };
|
|
setWindow(next);
|
|
queueEvent(() => emitter.emit("move", { window: next }));
|
|
commit();
|
|
return true;
|
|
}
|
|
function moveBy(id, dx, dy) {
|
|
const win = windows[id];
|
|
if (!win) return false;
|
|
return move(id, win.bounds.x + dx, win.bounds.y + dy);
|
|
}
|
|
function resize(id, patch) {
|
|
const win = windows[id];
|
|
if (!win || win.stage === "maximized" || win.stage === "minimized") return false;
|
|
const merged = { ...win.bounds, ...patch };
|
|
const size = normalizeSize(merged, win);
|
|
let bounds = { x: merged.x, y: merged.y, ...size };
|
|
if (keepInViewport) bounds = clampToViewport(bounds, viewport, minVisible);
|
|
const becameNormal = win.stage === "snapped";
|
|
if (boundsEqual(bounds, win.bounds) && !becameNormal) return true;
|
|
const next = becameNormal ? { ...win, stage: "normal", snapZone: null, restoreBounds: null, bounds } : { ...win, bounds };
|
|
setWindow(next);
|
|
if (becameNormal) queueEvent(() => emitter.emit("stage", { window: next, previous: "snapped" }));
|
|
queueEvent(() => emitter.emit("resize", { window: next }));
|
|
commit();
|
|
return true;
|
|
}
|
|
function update(id, patch) {
|
|
const win = windows[id];
|
|
if (!win) return false;
|
|
const next = {
|
|
...win,
|
|
title: patch.title ?? win.title,
|
|
layer: patch.layer ?? win.layer,
|
|
minSize: patch.minSize ?? win.minSize,
|
|
maxSize: patch.maxSize === void 0 ? win.maxSize : patch.maxSize,
|
|
draggable: patch.draggable ?? win.draggable,
|
|
resizable: patch.resizable ?? win.resizable,
|
|
closable: patch.closable ?? win.closable,
|
|
minimizable: patch.minimizable ?? win.minimizable,
|
|
maximizable: patch.maximizable ?? win.maximizable,
|
|
snappable: patch.snappable ?? win.snappable,
|
|
meta: patch.meta ? { ...win.meta, ...patch.meta } : win.meta
|
|
};
|
|
const size = normalizeSize(next.bounds, next);
|
|
const resized = size.width !== next.bounds.width || size.height !== next.bounds.height;
|
|
const finalWin = resized ? { ...next, bounds: { ...next.bounds, ...size } } : next;
|
|
setWindow(finalWin);
|
|
if (patch.layer && patch.layer !== win.layer) {
|
|
order = sortByLayer(order);
|
|
queueEvent(() => emitter.emit("order", { order }));
|
|
if (patch.layer === "modal" && topModalId() === id && focusedId !== id) {
|
|
const previous = focusedId;
|
|
focusedId = id;
|
|
emitFocus(finalWin, previous);
|
|
}
|
|
}
|
|
queueEvent(() => emitter.emit("update", { window: finalWin }));
|
|
if (resized) queueEvent(() => emitter.emit("resize", { window: finalWin }));
|
|
commit();
|
|
return true;
|
|
}
|
|
function setViewport(next) {
|
|
if (next.width === viewport.width && next.height === viewport.height) return;
|
|
viewport = { ...next };
|
|
reflowViewport();
|
|
snapshot = null;
|
|
batchDirty = true;
|
|
}
|
|
function minimized() {
|
|
return Object.values(windows).filter((win) => win.stage === "minimized").sort((a, b) => a.openedSeq - b.openedSeq);
|
|
}
|
|
function captureEntry() {
|
|
return { windows, order, focusedId };
|
|
}
|
|
function recordHistory() {
|
|
if (skipNextHistory) {
|
|
skipNextHistory = false;
|
|
return;
|
|
}
|
|
if (historyLimit <= 0) return;
|
|
if (interactionDepth > 0) {
|
|
if (interactionRecorded) return;
|
|
interactionRecorded = true;
|
|
}
|
|
past.push(pendingHistory);
|
|
if (past.length > historyLimit) past.shift();
|
|
future = [];
|
|
}
|
|
function transact(run) {
|
|
if (batchDepth === 0) pendingHistory = captureEntry();
|
|
batchDepth += 1;
|
|
try {
|
|
return run();
|
|
} finally {
|
|
batchDepth -= 1;
|
|
if (batchDepth === 0) {
|
|
if (batchDirty) {
|
|
batchDirty = false;
|
|
recordHistory();
|
|
snapshot = null;
|
|
flushEvents();
|
|
emitter.emit("change", { state: getState() });
|
|
}
|
|
pendingHistory = null;
|
|
}
|
|
}
|
|
}
|
|
function reflowViewport() {
|
|
for (const id of order) {
|
|
const win = windows[id];
|
|
if (win.stage === "maximized") {
|
|
const updated = { ...win, bounds: fullBounds() };
|
|
setWindow(updated);
|
|
queueEvent(() => emitter.emit("resize", { window: updated }));
|
|
} else if (win.stage === "snapped" && win.snapZone) {
|
|
const updated = { ...win, bounds: zoneBounds(win.snapZone, viewport) };
|
|
setWindow(updated);
|
|
queueEvent(() => emitter.emit("resize", { window: updated }));
|
|
} else if (win.stage === "normal" && keepInViewport) {
|
|
const bounds = clampToViewport(win.bounds, viewport, minVisible);
|
|
if (!boundsEqual(bounds, win.bounds)) {
|
|
const updated = { ...win, bounds };
|
|
setWindow(updated);
|
|
queueEvent(() => emitter.emit("move", { window: updated }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function applyEntry(entry) {
|
|
windows = entry.windows;
|
|
order = [...entry.order];
|
|
focusedId = entry.focusedId;
|
|
reflowViewport();
|
|
commit();
|
|
}
|
|
function undo() {
|
|
const entry = past.pop();
|
|
if (!entry) return false;
|
|
future.push(captureEntry());
|
|
skipNextHistory = true;
|
|
applyEntry(entry);
|
|
return true;
|
|
}
|
|
function redo() {
|
|
const entry = future.pop();
|
|
if (!entry) return false;
|
|
past.push(captureEntry());
|
|
skipNextHistory = true;
|
|
applyEntry(entry);
|
|
return true;
|
|
}
|
|
function beginInteraction() {
|
|
interactionDepth += 1;
|
|
if (interactionDepth === 1) interactionRecorded = false;
|
|
}
|
|
function endInteraction() {
|
|
if (interactionDepth > 0) interactionDepth -= 1;
|
|
}
|
|
function clearHistory() {
|
|
past.length = 0;
|
|
future = [];
|
|
}
|
|
function saveLayout(name) {
|
|
const data = serialize();
|
|
layouts.set(name, structuredClone(data));
|
|
return data;
|
|
}
|
|
function loadLayout(name) {
|
|
const data = layouts.get(name);
|
|
if (!data) return false;
|
|
return hydrate(structuredClone(data));
|
|
}
|
|
function getLayout(name) {
|
|
const data = layouts.get(name);
|
|
return data ? structuredClone(data) : void 0;
|
|
}
|
|
function setLayout(name, data) {
|
|
if (!isValidSerialized(data)) return false;
|
|
layouts.set(name, structuredClone(data));
|
|
return true;
|
|
}
|
|
function deleteLayout(name) {
|
|
return layouts.delete(name);
|
|
}
|
|
function layoutNames() {
|
|
return [...layouts.keys()];
|
|
}
|
|
function arrange(mode) {
|
|
const ids = order.filter((id) => windows[id].stage !== "minimized");
|
|
if (ids.length === 0) return;
|
|
if (mode === "cascade") {
|
|
ids.forEach((id, index) => {
|
|
const win = windows[id];
|
|
const size = win.restoreBounds ?? win.bounds;
|
|
restoreTo(id, {
|
|
x: cascadeOrigin.x + index % 10 * cascadeOffset,
|
|
y: cascadeOrigin.y + index % 10 * cascadeOffset,
|
|
width: size.width,
|
|
height: size.height
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
const cols = Math.ceil(Math.sqrt(ids.length));
|
|
const rows = Math.ceil(ids.length / cols);
|
|
const cellWidth = Math.floor(viewport.width / cols);
|
|
const cellHeight = Math.floor(viewport.height / rows);
|
|
ids.forEach((id, index) => {
|
|
restoreTo(id, {
|
|
x: index % cols * cellWidth,
|
|
y: Math.floor(index / cols) * cellHeight,
|
|
width: cellWidth,
|
|
height: cellHeight
|
|
});
|
|
});
|
|
}
|
|
function minimizeAll() {
|
|
for (const id of [...order]) minimize(id);
|
|
}
|
|
function restoreAll() {
|
|
for (const win of minimized()) restore(win.id);
|
|
}
|
|
function serialize() {
|
|
return {
|
|
version: 1,
|
|
windows: order.map((id) => windows[id]).map((win) => ({
|
|
...win,
|
|
bounds: { ...win.bounds },
|
|
restoreBounds: win.restoreBounds ? { ...win.restoreBounds } : null,
|
|
minSize: { ...win.minSize },
|
|
maxSize: win.maxSize ? {
|
|
width: Number.isFinite(win.maxSize.width) ? win.maxSize.width : null,
|
|
height: Number.isFinite(win.maxSize.height) ? win.maxSize.height : null
|
|
} : null,
|
|
meta: { ...win.meta }
|
|
})),
|
|
order: [...order],
|
|
focusedId
|
|
};
|
|
}
|
|
function isBounds(value) {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
const bounds = value;
|
|
return typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number";
|
|
}
|
|
function isSize(value) {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
const size = value;
|
|
return typeof size.width === "number" && typeof size.height === "number";
|
|
}
|
|
function isSizeAxis(value) {
|
|
return value === null || typeof value === "number";
|
|
}
|
|
function readMaxSize(value) {
|
|
if (typeof value !== "object" || value === null) return null;
|
|
const size = value;
|
|
if (!isSizeAxis(size.width) || !isSizeAxis(size.height)) return null;
|
|
if (size.width === null && size.height === null) return null;
|
|
return {
|
|
width: size.width ?? Number.POSITIVE_INFINITY,
|
|
height: size.height ?? Number.POSITIVE_INFINITY
|
|
};
|
|
}
|
|
function isSerializedWindow(value) {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
const win = value;
|
|
return typeof win.id === "string" && win.id.length > 0 && isBounds(win.bounds) && STAGES.includes(win.stage) && LAYERS.includes(win.layer);
|
|
}
|
|
function isValidSerialized(data) {
|
|
if (typeof data !== "object" || data === null) return false;
|
|
if (data.version !== 1 || !Array.isArray(data.windows)) return false;
|
|
if (!data.windows.every(isSerializedWindow)) return false;
|
|
const ids = new Set(data.windows.map((win) => win.id));
|
|
return ids.size === data.windows.length;
|
|
}
|
|
function hydrate(data) {
|
|
if (!isValidSerialized(data)) return false;
|
|
const previousWindows = windows;
|
|
const nextWindows = {};
|
|
let maxSeq = 0;
|
|
for (const raw of data.windows) {
|
|
if (nextWindows[raw.id]) return false;
|
|
const win = {
|
|
id: raw.id,
|
|
title: typeof raw.title === "string" ? raw.title : "Window",
|
|
bounds: { ...raw.bounds },
|
|
restoreBounds: raw.restoreBounds && isBounds(raw.restoreBounds) ? { ...raw.restoreBounds } : null,
|
|
restoreStage: STAGES.includes(raw.restoreStage) ? raw.restoreStage : null,
|
|
stage: raw.stage,
|
|
snapZone: ZONES.includes(raw.snapZone) ? raw.snapZone : null,
|
|
layer: raw.layer,
|
|
minSize: isSize(raw.minSize) ? { ...raw.minSize } : { width: 160, height: 100 },
|
|
maxSize: readMaxSize(raw.maxSize),
|
|
openedSeq: typeof raw.openedSeq === "number" ? raw.openedSeq : ++maxSeq,
|
|
draggable: raw.draggable !== false,
|
|
resizable: raw.resizable !== false,
|
|
closable: raw.closable !== false,
|
|
minimizable: raw.minimizable !== false,
|
|
maximizable: raw.maximizable !== false,
|
|
snappable: raw.snappable !== false,
|
|
meta: raw.meta && typeof raw.meta === "object" ? raw.meta : {}
|
|
};
|
|
maxSeq = Math.max(maxSeq, win.openedSeq);
|
|
nextWindows[win.id] = win;
|
|
}
|
|
const requestedOrder = Array.isArray(data.order) ? data.order : [];
|
|
const seen = /* @__PURE__ */ new Set();
|
|
const nextOrder = [];
|
|
for (const id of requestedOrder) {
|
|
if (typeof id === "string" && nextWindows[id] && !seen.has(id)) {
|
|
seen.add(id);
|
|
nextOrder.push(id);
|
|
}
|
|
}
|
|
for (const id of Object.keys(nextWindows)) {
|
|
if (!seen.has(id)) nextOrder.push(id);
|
|
}
|
|
windows = nextWindows;
|
|
order = nextOrder;
|
|
order = sortByLayer(order);
|
|
seq = maxSeq;
|
|
focusedId = data.focusedId && windows[data.focusedId] ? data.focusedId : null;
|
|
if (focusedId && windows[focusedId]?.stage === "minimized") focusedId = null;
|
|
if (!focusedId) focusTop();
|
|
const modal = topModalId();
|
|
if (modal) {
|
|
const focusedWin = focusedId ? windows[focusedId] : null;
|
|
if (focusedWin?.layer !== "modal") focusedId = modal;
|
|
}
|
|
for (const id of Object.keys(previousWindows)) {
|
|
if (!windows[id]) {
|
|
const closed = previousWindows[id];
|
|
queueEvent(() => emitter.emit("close", { window: closed }));
|
|
}
|
|
}
|
|
for (const id of order) {
|
|
if (!previousWindows[id]) {
|
|
const opened = windows[id];
|
|
queueEvent(() => emitter.emit("open", { window: opened }));
|
|
}
|
|
}
|
|
queueEvent(() => emitter.emit("order", { order }));
|
|
clearHistory();
|
|
skipNextHistory = true;
|
|
commit();
|
|
return true;
|
|
}
|
|
function destroy() {
|
|
emitter.clear();
|
|
}
|
|
return {
|
|
open: (init) => transact(() => open(init)),
|
|
close: (id) => transact(() => close(id)),
|
|
closeAll: () => transact(closeAll),
|
|
focus: (id) => transact(() => focus(id)),
|
|
blur: () => transact(blur),
|
|
cycleFocus: (direction) => transact(() => cycleFocus(direction)),
|
|
minimize: (id) => transact(() => minimize(id)),
|
|
maximize: (id) => transact(() => maximize(id)),
|
|
toggleMaximize: (id) => transact(() => toggleMaximize(id)),
|
|
restore: (id) => transact(() => restore(id)),
|
|
restoreTo: (id, bounds) => transact(() => restoreTo(id, bounds)),
|
|
snap: (id, zone) => transact(() => snap(id, zone)),
|
|
move: (id, x, y) => transact(() => move(id, x, y)),
|
|
moveBy: (id, dx, dy) => transact(() => moveBy(id, dx, dy)),
|
|
resize: (id, patch) => transact(() => resize(id, patch)),
|
|
update: (id, patch) => transact(() => update(id, patch)),
|
|
get: (id) => windows[id],
|
|
getState,
|
|
minimized,
|
|
setViewport: (next) => transact(() => setViewport(next)),
|
|
batch: (run) => transact(run),
|
|
serialize,
|
|
hydrate: (data) => transact(() => hydrate(data)),
|
|
undo: () => transact(undo),
|
|
redo: () => transact(redo),
|
|
canUndo: () => past.length > 0,
|
|
canRedo: () => future.length > 0,
|
|
beginInteraction,
|
|
endInteraction,
|
|
saveLayout,
|
|
loadLayout: (name) => transact(() => loadLayout(name)),
|
|
getLayout,
|
|
setLayout,
|
|
deleteLayout,
|
|
layoutNames,
|
|
arrange: (mode) => transact(() => arrange(mode)),
|
|
minimizeAll: () => transact(minimizeAll),
|
|
restoreAll: () => transact(restoreAll),
|
|
subscribe: (listener) => emitter.on("change", ({ state }) => listener(state)),
|
|
on: (event, listener) => emitter.on(event, listener),
|
|
destroy
|
|
};
|
|
}
|
|
|
|
// src/dom/animate.ts
|
|
function prefersReducedMotion(win) {
|
|
return win.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
}
|
|
function flipFromTarget(source, target, options = {}) {
|
|
const view = source.ownerDocument.defaultView;
|
|
if (!view || prefersReducedMotion(view)) return;
|
|
if (typeof source.animate !== "function") return;
|
|
const from = target.getBoundingClientRect();
|
|
const to = source.getBoundingClientRect();
|
|
if (from.width === 0 || to.width === 0) return;
|
|
const ghost = source.ownerDocument.createElement("div");
|
|
const style = view.getComputedStyle(source);
|
|
ghost.style.cssText = `position:fixed;left:${to.left}px;top:${to.top}px;width:${to.width}px;height:${to.height}px;margin:0;pointer-events:none;z-index:2147483647;border-radius:${style.borderRadius};background:${style.backgroundColor};box-shadow:${style.boxShadow};transform-origin:top left;will-change:transform,opacity`;
|
|
source.ownerDocument.body.append(ghost);
|
|
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
|
|
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
|
|
const scaleX = Math.max(from.width / to.width, 0.05);
|
|
const scaleY = Math.max(from.height / to.height, 0.05);
|
|
const animation = ghost.animate(
|
|
[
|
|
{ transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`, opacity: 0.2 },
|
|
{ transform: "translate(0, 0) scale(1, 1)", opacity: 0.9 }
|
|
],
|
|
{
|
|
duration: options.duration ?? 260,
|
|
easing: options.easing ?? "cubic-bezier(0.32, 0.72, 0, 1)"
|
|
}
|
|
);
|
|
animation.onfinish = () => ghost.remove();
|
|
animation.oncancel = () => ghost.remove();
|
|
}
|
|
function flipToTarget(source, target, options = {}) {
|
|
const view = source.ownerDocument.defaultView;
|
|
if (!view || prefersReducedMotion(view)) return;
|
|
if (typeof source.animate !== "function") return;
|
|
const from = source.getBoundingClientRect();
|
|
const to = target.getBoundingClientRect();
|
|
if (from.width === 0 || to.width === 0) return;
|
|
const ghost = source.ownerDocument.createElement("div");
|
|
const style = view.getComputedStyle(source);
|
|
ghost.style.cssText = `position:fixed;left:${from.left}px;top:${from.top}px;width:${from.width}px;height:${from.height}px;margin:0;pointer-events:none;z-index:2147483647;border-radius:${style.borderRadius};background:${style.backgroundColor};box-shadow:${style.boxShadow};transform-origin:top left;will-change:transform,opacity`;
|
|
source.ownerDocument.body.append(ghost);
|
|
const dx = to.left + to.width / 2 - (from.left + from.width / 2);
|
|
const dy = to.top + to.height / 2 - (from.top + from.height / 2);
|
|
const scaleX = Math.max(to.width / from.width, 0.05);
|
|
const scaleY = Math.max(to.height / from.height, 0.05);
|
|
const animation = ghost.animate(
|
|
[
|
|
{ transform: "translate(0, 0) scale(1, 1)", opacity: 0.9 },
|
|
{ transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`, opacity: 0.2 }
|
|
],
|
|
{
|
|
duration: options.duration ?? 260,
|
|
easing: options.easing ?? "cubic-bezier(0.32, 0.72, 0, 1)"
|
|
}
|
|
);
|
|
animation.onfinish = () => ghost.remove();
|
|
animation.oncancel = () => ghost.remove();
|
|
}
|
|
|
|
// src/dom/announcer.ts
|
|
var defaultMessages = {
|
|
opened: (title) => `${title} window opened`,
|
|
closed: (title) => `${title} window closed`,
|
|
minimized: (title) => `${title} minimized`,
|
|
restored: (title) => `${title} restored`,
|
|
maximized: (title) => `${title} maximized`,
|
|
snapped: (title, zone) => `${title} snapped to ${zone.replace("-", " ")}`,
|
|
focused: (title) => `${title} focused`
|
|
};
|
|
function createAnnouncer(wm, container, messages = {}) {
|
|
const dict = { ...defaultMessages, ...messages };
|
|
const element = container.ownerDocument.createElement("div");
|
|
element.setAttribute("role", "status");
|
|
element.setAttribute("aria-live", "polite");
|
|
element.dataset.wmAnnouncer = "";
|
|
element.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0";
|
|
container.append(element);
|
|
let clearTimer;
|
|
function announce(message) {
|
|
element.textContent = message;
|
|
if (clearTimer !== void 0) clearTimeout(clearTimer);
|
|
clearTimer = setTimeout(() => {
|
|
element.textContent = "";
|
|
}, 2e3);
|
|
}
|
|
const unsubscribers = [
|
|
wm.on("open", ({ window: win }) => announce(dict.opened(win.title))),
|
|
wm.on("close", ({ window: win }) => announce(dict.closed(win.title))),
|
|
wm.on("stage", ({ window: win, previous }) => {
|
|
if (win.stage === "minimized") announce(dict.minimized(win.title));
|
|
else if (win.stage === "maximized") announce(dict.maximized(win.title));
|
|
else if (win.stage === "snapped" && win.snapZone)
|
|
announce(dict.snapped(win.title, win.snapZone));
|
|
else if (win.stage === "normal" && previous !== "normal") announce(dict.restored(win.title));
|
|
})
|
|
];
|
|
return {
|
|
element,
|
|
announce,
|
|
destroy() {
|
|
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
if (clearTimer !== void 0) clearTimeout(clearTimer);
|
|
element.remove();
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/dom/shared.ts
|
|
var INTERACTIVE_SELECTOR = "button, input, select, textarea, a[href], [contenteditable], [data-wm-close], [data-wm-minimize], [data-wm-maximize]";
|
|
function windowOf(element) {
|
|
const view = element.ownerDocument.defaultView;
|
|
if (!view) throw new Error("wmkit: desktop element is not attached to a document");
|
|
return view;
|
|
}
|
|
|
|
// src/dom/drag.ts
|
|
function createDragStarter(ctx) {
|
|
const { wm, doc, view } = ctx;
|
|
return function startDrag(id, handle, event) {
|
|
const win = wm.get(id);
|
|
if (!win?.draggable || event.button !== 0 || ctx.currentDrag()) return;
|
|
const target = event.target;
|
|
if (target?.closest(INTERACTIVE_SELECTOR)) return;
|
|
event.preventDefault();
|
|
const releaseRect = ctx.trackRect();
|
|
const point = ctx.toLocal(event);
|
|
const startBounds = win.bounds;
|
|
const session = {
|
|
id,
|
|
pointerId: event.pointerId,
|
|
grabDX: point.x - startBounds.x,
|
|
grabDY: point.y - startBounds.y,
|
|
grabRatio: clamp((point.x - startBounds.x) / startBounds.width, 0.05, 0.95),
|
|
startBounds,
|
|
startStage: win.stage,
|
|
startZone: win.snapZone,
|
|
startRestoreBounds: win.restoreBounds,
|
|
restored: win.stage === "normal",
|
|
moved: false,
|
|
zone: null,
|
|
raf: 0,
|
|
pendingX: 0,
|
|
pendingY: 0,
|
|
hasPending: false,
|
|
finish: () => {
|
|
}
|
|
};
|
|
const el = ctx.windowElement(id);
|
|
function flush() {
|
|
if (!session.hasPending || ctx.currentDrag() !== session) return;
|
|
session.hasPending = false;
|
|
session.raf = 0;
|
|
const current = wm.get(id);
|
|
if (!current) return;
|
|
if (!session.restored) {
|
|
const source = current.restoreBounds ?? session.startBounds;
|
|
const width = source.width;
|
|
const height = source.height;
|
|
wm.restoreTo(id, {
|
|
x: session.pendingX - width * session.grabRatio,
|
|
y: session.pendingY - Math.min(session.grabDY, 24),
|
|
width,
|
|
height
|
|
});
|
|
session.restored = true;
|
|
const restoredWin = wm.get(id);
|
|
if (restoredWin) {
|
|
session.grabDX = session.pendingX - restoredWin.bounds.x;
|
|
session.grabDY = session.pendingY - restoredWin.bounds.y;
|
|
}
|
|
return;
|
|
}
|
|
let nextX = session.pendingX - session.grabDX;
|
|
let nextY = session.pendingY - session.grabDY;
|
|
if (ctx.magnetThreshold > 0) {
|
|
const state = wm.getState();
|
|
const targets = [
|
|
{ x: 0, y: 0, width: state.viewport.width, height: state.viewport.height }
|
|
];
|
|
for (const otherId of state.order) {
|
|
if (otherId === id) continue;
|
|
const other = state.windows[otherId];
|
|
if (other && other.stage !== "minimized") targets.push(other.bounds);
|
|
}
|
|
const magnet = magnetize(
|
|
{ x: nextX, y: nextY, width: current.bounds.width, height: current.bounds.height },
|
|
targets,
|
|
ctx.magnetThreshold
|
|
);
|
|
nextX = magnet.x;
|
|
nextY = magnet.y;
|
|
}
|
|
wm.move(id, nextX, nextY);
|
|
if (ctx.snapEnabled && current.snappable) {
|
|
const viewport = wm.getState().viewport;
|
|
const rawZone = detectSnapZone(session.pendingX, session.pendingY, viewport, ctx.snapDetect);
|
|
let zone = rawZone;
|
|
if (rawZone === "top") {
|
|
if (ctx.topEdge === "maximize") zone = current.maximizable ? "maximize" : null;
|
|
else if (ctx.topEdge === "none") zone = null;
|
|
}
|
|
session.zone = zone;
|
|
if (zone) {
|
|
ctx.showPreview(
|
|
zone === "maximize" ? { x: 0, y: 0, width: viewport.width, height: viewport.height } : zoneBounds(zone, viewport)
|
|
);
|
|
} else {
|
|
ctx.hidePreview();
|
|
}
|
|
}
|
|
}
|
|
function onMove(moveEvent) {
|
|
if (moveEvent.pointerId !== session.pointerId) return;
|
|
const movePoint = ctx.toLocal(moveEvent);
|
|
if (!session.moved) {
|
|
const travelled = Math.abs(movePoint.x - (session.startBounds.x + session.grabDX)) + Math.abs(movePoint.y - (session.startBounds.y + session.grabDY));
|
|
if (travelled < 3 && session.restored) return;
|
|
session.moved = true;
|
|
if (el) {
|
|
el.dataset.wmDragging = "";
|
|
el.style.willChange = "transform";
|
|
}
|
|
}
|
|
session.pendingX = movePoint.x;
|
|
session.pendingY = movePoint.y;
|
|
session.hasPending = true;
|
|
if (session.raf === 0) session.raf = view.requestAnimationFrame(flush);
|
|
}
|
|
function onUp(upEvent) {
|
|
if (upEvent.pointerId !== session.pointerId) return;
|
|
finish(false);
|
|
}
|
|
function onCancel(cancelEvent) {
|
|
if (cancelEvent.pointerId !== session.pointerId) return;
|
|
finish(true);
|
|
}
|
|
function onKeydown(keyEvent) {
|
|
if (keyEvent.key === "Escape") {
|
|
keyEvent.preventDefault();
|
|
finish(true);
|
|
}
|
|
}
|
|
function finish(cancelled) {
|
|
if (ctx.currentDrag() !== session) return;
|
|
if (session.raf !== 0) view.cancelAnimationFrame(session.raf);
|
|
if (session.hasPending && !cancelled) flush();
|
|
ctx.releaseDrag(session);
|
|
releaseRect();
|
|
handle.removeEventListener("pointermove", onMove);
|
|
handle.removeEventListener("pointerup", onUp);
|
|
handle.removeEventListener("pointercancel", onCancel);
|
|
doc.removeEventListener("keydown", onKeydown, true);
|
|
if (handle.hasPointerCapture(session.pointerId)) {
|
|
handle.releasePointerCapture(session.pointerId);
|
|
}
|
|
if (el) {
|
|
delete el.dataset.wmDragging;
|
|
el.style.willChange = "";
|
|
}
|
|
ctx.hidePreview();
|
|
if (cancelled) {
|
|
if (session.moved && session.restored) {
|
|
if (session.startStage === "maximized") {
|
|
wm.restoreTo(id, session.startRestoreBounds ?? session.startBounds);
|
|
wm.maximize(id);
|
|
} else if (session.startStage === "snapped" && session.startZone) {
|
|
wm.restoreTo(id, session.startRestoreBounds ?? session.startBounds);
|
|
wm.snap(id, session.startZone);
|
|
} else {
|
|
wm.move(id, session.startBounds.x, session.startBounds.y);
|
|
}
|
|
}
|
|
wm.endInteraction();
|
|
return;
|
|
}
|
|
if (session.moved && session.zone) {
|
|
if (session.zone === "maximize") wm.maximize(id);
|
|
else wm.snap(id, session.zone);
|
|
}
|
|
wm.endInteraction();
|
|
}
|
|
session.finish = finish;
|
|
wm.beginInteraction();
|
|
ctx.claimDrag(session);
|
|
handle.setPointerCapture(event.pointerId);
|
|
handle.addEventListener("pointermove", onMove);
|
|
handle.addEventListener("pointerup", onUp);
|
|
handle.addEventListener("pointercancel", onCancel);
|
|
doc.addEventListener("keydown", onKeydown, true);
|
|
};
|
|
}
|
|
|
|
// src/dom/resize.ts
|
|
var RESIZE_DIRECTIONS = ["n", "e", "s", "w", "ne", "nw", "se", "sw"];
|
|
var RESIZE_CURSORS = {
|
|
n: "ns-resize",
|
|
s: "ns-resize",
|
|
e: "ew-resize",
|
|
w: "ew-resize",
|
|
ne: "nesw-resize",
|
|
sw: "nesw-resize",
|
|
nw: "nwse-resize",
|
|
se: "nwse-resize"
|
|
};
|
|
function createResizeStarter(ctx) {
|
|
const { wm, doc, view } = ctx;
|
|
return function startResize(id, direction, event) {
|
|
const win = wm.get(id);
|
|
if (!win?.resizable || event.button !== 0 || ctx.currentDrag()) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const handleEl = event.currentTarget;
|
|
wm.beginInteraction();
|
|
const releaseRect = ctx.trackRect();
|
|
const startPoint = ctx.toLocal(event);
|
|
const start = win.bounds;
|
|
const minSize = win.minSize;
|
|
const maxSize = win.maxSize;
|
|
let raf = 0;
|
|
let pendingX = 0;
|
|
let pendingY = 0;
|
|
let hasPending = false;
|
|
const el = ctx.windowElement(id);
|
|
if (el) el.dataset.wmResizing = direction;
|
|
function clampWidth(width) {
|
|
return clamp(width, minSize.width, maxSize ? maxSize.width : Number.POSITIVE_INFINITY);
|
|
}
|
|
function clampHeight(height) {
|
|
return clamp(height, minSize.height, maxSize ? maxSize.height : Number.POSITIVE_INFINITY);
|
|
}
|
|
function flush() {
|
|
if (!hasPending) return;
|
|
hasPending = false;
|
|
raf = 0;
|
|
const dx = pendingX - startPoint.x;
|
|
const dy = pendingY - startPoint.y;
|
|
const next = { ...start };
|
|
if (direction.includes("e")) next.width = clampWidth(start.width + dx);
|
|
if (direction.includes("s")) next.height = clampHeight(start.height + dy);
|
|
if (direction.includes("w")) {
|
|
next.width = clampWidth(start.width - dx);
|
|
next.x = start.x + (start.width - next.width);
|
|
}
|
|
if (direction.includes("n")) {
|
|
next.height = clampHeight(start.height - dy);
|
|
next.y = start.y + (start.height - next.height);
|
|
}
|
|
wm.resize(id, next);
|
|
}
|
|
function onMove(moveEvent) {
|
|
if (moveEvent.pointerId !== event.pointerId) return;
|
|
const movePoint = ctx.toLocal(moveEvent);
|
|
pendingX = movePoint.x;
|
|
pendingY = movePoint.y;
|
|
hasPending = true;
|
|
if (raf === 0) raf = view.requestAnimationFrame(flush);
|
|
}
|
|
function finish(cancelled) {
|
|
if (raf !== 0) view.cancelAnimationFrame(raf);
|
|
if (hasPending && !cancelled) flush();
|
|
releaseRect();
|
|
handleEl.removeEventListener("pointermove", onMove);
|
|
handleEl.removeEventListener("pointerup", onUp);
|
|
handleEl.removeEventListener("pointercancel", onCancelPointer);
|
|
doc.removeEventListener("keydown", onKeydown, true);
|
|
if (handleEl.hasPointerCapture(event.pointerId)) {
|
|
handleEl.releasePointerCapture(event.pointerId);
|
|
}
|
|
if (el) delete el.dataset.wmResizing;
|
|
if (cancelled) wm.resize(id, start);
|
|
wm.endInteraction();
|
|
}
|
|
function onUp(upEvent) {
|
|
if (upEvent.pointerId !== event.pointerId) return;
|
|
finish(false);
|
|
}
|
|
function onCancelPointer(cancelEvent) {
|
|
if (cancelEvent.pointerId !== event.pointerId) return;
|
|
finish(true);
|
|
}
|
|
function onKeydown(keyEvent) {
|
|
if (keyEvent.key === "Escape") {
|
|
keyEvent.preventDefault();
|
|
finish(true);
|
|
}
|
|
}
|
|
handleEl.setPointerCapture(event.pointerId);
|
|
handleEl.addEventListener("pointermove", onMove);
|
|
handleEl.addEventListener("pointerup", onUp);
|
|
handleEl.addEventListener("pointercancel", onCancelPointer);
|
|
doc.addEventListener("keydown", onKeydown, true);
|
|
};
|
|
}
|
|
function createResizeHandles(doc, edge, corner) {
|
|
const base = "position:absolute;touch-action:none;user-select:none;-webkit-user-select:none;";
|
|
const styles = {
|
|
n: `top:${-edge / 2}px;left:${corner}px;right:${corner}px;height:${edge}px`,
|
|
s: `bottom:${-edge / 2}px;left:${corner}px;right:${corner}px;height:${edge}px`,
|
|
e: `right:${-edge / 2}px;top:${corner}px;bottom:${corner}px;width:${edge}px`,
|
|
w: `left:${-edge / 2}px;top:${corner}px;bottom:${corner}px;width:${edge}px`,
|
|
ne: `top:${-edge / 2}px;right:${-edge / 2}px;width:${corner}px;height:${corner}px`,
|
|
nw: `top:${-edge / 2}px;left:${-edge / 2}px;width:${corner}px;height:${corner}px`,
|
|
se: `bottom:${-edge / 2}px;right:${-edge / 2}px;width:${corner}px;height:${corner}px`,
|
|
sw: `bottom:${-edge / 2}px;left:${-edge / 2}px;width:${corner}px;height:${corner}px`
|
|
};
|
|
return RESIZE_DIRECTIONS.map((direction) => {
|
|
const element = doc.createElement("div");
|
|
element.dataset.wmResize = direction;
|
|
element.setAttribute("aria-hidden", "true");
|
|
element.style.cssText = `${base}${styles[direction]};cursor:${RESIZE_CURSORS[direction]}`;
|
|
return { element, direction };
|
|
});
|
|
}
|
|
|
|
// src/dom/controller.ts
|
|
function attachDesktop(wm, element, options = {}) {
|
|
const doc = element.ownerDocument;
|
|
const view = windowOf(element);
|
|
const coarsePointer = typeof view.matchMedia === "function" && view.matchMedia("(pointer: coarse)").matches;
|
|
const snapEnabled = options.snap !== false;
|
|
const snapOptions = typeof options.snap === "object" ? options.snap : {};
|
|
const snapPreviewEnabled = snapOptions.preview !== false;
|
|
const topEdge = snapOptions.topEdge ?? "maximize";
|
|
const keyboardEnabled = options.keyboard !== false;
|
|
const keyboardOptions = typeof options.keyboard === "object" ? options.keyboard : {};
|
|
const moveStep = keyboardOptions.moveStep ?? 16;
|
|
const cycleEnabled = keyboardOptions.cycle !== false;
|
|
const hitEdge = options.hitAreas?.edge ?? (coarsePointer ? 16 : 8);
|
|
const hitCorner = options.hitAreas?.corner ?? (coarsePointer ? 24 : 12);
|
|
const magnetThreshold = options.magnetism === false ? 0 : (typeof options.magnetism === "object" ? options.magnetism.threshold : void 0) ?? (coarsePointer ? 12 : 8);
|
|
element.dataset.wmDesktop = "";
|
|
if (view.getComputedStyle(element).position === "static") {
|
|
element.style.position = "relative";
|
|
}
|
|
const registry = /* @__PURE__ */ new Map();
|
|
const cleanup = [];
|
|
let lastOrder = null;
|
|
let lastFocused = null;
|
|
let drag = null;
|
|
let cachedRect = null;
|
|
let rectUsers = 0;
|
|
let announcer = null;
|
|
if (options.announce !== false) {
|
|
announcer = createAnnouncer(
|
|
wm,
|
|
element,
|
|
typeof options.announce === "object" ? options.announce : {}
|
|
);
|
|
cleanup.push(() => announcer?.destroy());
|
|
}
|
|
let preview = null;
|
|
function showPreview(bounds) {
|
|
if (!snapPreviewEnabled) return;
|
|
if (!preview) {
|
|
preview = doc.createElement("div");
|
|
preview.dataset.wmSnapPreview = "";
|
|
preview.style.cssText = "position:absolute;left:0;top:0;pointer-events:none;display:none;z-index:2147483646";
|
|
element.append(preview);
|
|
}
|
|
preview.style.display = "block";
|
|
preview.style.transform = `translate3d(${bounds.x}px, ${bounds.y}px, 0)`;
|
|
preview.style.width = `${bounds.width}px`;
|
|
preview.style.height = `${bounds.height}px`;
|
|
}
|
|
function hidePreview() {
|
|
if (preview) preview.style.display = "none";
|
|
}
|
|
const ctx = {
|
|
wm,
|
|
doc,
|
|
view,
|
|
toLocal(event) {
|
|
const rect = cachedRect ?? element.getBoundingClientRect();
|
|
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
},
|
|
trackRect() {
|
|
if (rectUsers === 0) {
|
|
const rect = element.getBoundingClientRect();
|
|
cachedRect = { left: rect.left, top: rect.top };
|
|
}
|
|
rectUsers += 1;
|
|
let released = false;
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
rectUsers -= 1;
|
|
if (rectUsers === 0) cachedRect = null;
|
|
};
|
|
},
|
|
windowElement: (id) => registry.get(id)?.element,
|
|
showPreview,
|
|
hidePreview,
|
|
snapEnabled,
|
|
snapDetect: {
|
|
threshold: snapOptions.threshold ?? (coarsePointer ? 20 : 12),
|
|
cornerSize: snapOptions.cornerSize ?? (coarsePointer ? 96 : 64)
|
|
},
|
|
topEdge,
|
|
hitEdge,
|
|
hitCorner,
|
|
magnetThreshold,
|
|
currentDrag: () => drag,
|
|
claimDrag(session) {
|
|
drag = session;
|
|
},
|
|
releaseDrag(session) {
|
|
if (drag === session) drag = null;
|
|
}
|
|
};
|
|
const startDrag = createDragStarter(ctx);
|
|
const startResize = createResizeStarter(ctx);
|
|
if (options.autoViewport !== false) {
|
|
const applyViewport = () => wm.setViewport({ width: element.clientWidth, height: element.clientHeight });
|
|
applyViewport();
|
|
const observer = new ResizeObserver(applyViewport);
|
|
observer.observe(element);
|
|
cleanup.push(() => observer.disconnect());
|
|
}
|
|
function syncWindow(attached, win, zIndex) {
|
|
const el = attached.element;
|
|
const firstSync = attached.lastState === null;
|
|
if (firstSync) el.style.transition = "none";
|
|
if (attached.lastState !== win) {
|
|
el.style.transform = `translate3d(${win.bounds.x}px, ${win.bounds.y}px, 0)`;
|
|
el.style.width = `${win.bounds.width}px`;
|
|
el.style.height = `${win.bounds.height}px`;
|
|
el.dataset.wmStage = win.stage;
|
|
el.dataset.wmLayer = win.layer;
|
|
el.hidden = win.stage === "minimized";
|
|
el.setAttribute("aria-label", win.title);
|
|
if (win.layer === "modal") el.setAttribute("aria-modal", "true");
|
|
else el.removeAttribute("aria-modal");
|
|
for (const handle of attached.handles) {
|
|
handle.style.display = win.resizable && win.stage === "normal" ? "" : "none";
|
|
}
|
|
attached.lastState = win;
|
|
}
|
|
if (attached.lastZ !== zIndex) {
|
|
el.style.zIndex = String(zIndex + 1);
|
|
attached.lastZ = zIndex;
|
|
}
|
|
if (firstSync) {
|
|
void el.offsetWidth;
|
|
el.style.transition = "";
|
|
}
|
|
}
|
|
function syncAll() {
|
|
const state = wm.getState();
|
|
const orderChanged = state.order !== lastOrder;
|
|
state.order.forEach((id, index) => {
|
|
const attached = registry.get(id);
|
|
const win = state.windows[id];
|
|
if (!attached || !win) return;
|
|
if (orderChanged || attached.lastState !== win) syncWindow(attached, win, index);
|
|
});
|
|
if (state.focusedId !== lastFocused) {
|
|
if (lastFocused) {
|
|
const prev = registry.get(lastFocused);
|
|
if (prev) delete prev.element.dataset.wmFocused;
|
|
}
|
|
if (state.focusedId) {
|
|
const next = registry.get(state.focusedId);
|
|
if (next) next.element.dataset.wmFocused = "";
|
|
}
|
|
lastFocused = state.focusedId;
|
|
}
|
|
lastOrder = state.order;
|
|
}
|
|
cleanup.push(wm.subscribe(syncAll));
|
|
cleanup.push(
|
|
wm.on("focus", ({ window: win }) => {
|
|
const attached = registry.get(win.id);
|
|
if (!attached) return;
|
|
if (!attached.element.contains(doc.activeElement)) {
|
|
attached.element.focus({ preventScroll: true });
|
|
}
|
|
})
|
|
);
|
|
cleanup.push(
|
|
wm.on("modalblocked", ({ window: win }) => {
|
|
const attached = registry.get(win.id);
|
|
if (!attached) return;
|
|
delete attached.element.dataset.wmFlash;
|
|
void attached.element.offsetWidth;
|
|
attached.element.dataset.wmFlash = "";
|
|
})
|
|
);
|
|
cleanup.push(
|
|
wm.on("stage", ({ window: win, previous }) => {
|
|
const attached = registry.get(win.id);
|
|
if (!attached) return;
|
|
if (win.stage === "minimized" && previous !== "minimized") {
|
|
const target = options.minimizeTarget?.(win);
|
|
if (target) flipToTarget(attached.element, target);
|
|
} else if (previous === "minimized" && win.stage !== "minimized") {
|
|
const target = options.minimizeTarget?.(win);
|
|
if (target) flipFromTarget(attached.element, target);
|
|
}
|
|
})
|
|
);
|
|
if (keyboardEnabled && cycleEnabled) {
|
|
const onDesktopKeydown = (event) => {
|
|
if (event.key === "F6") {
|
|
event.preventDefault();
|
|
wm.cycleFocus(event.shiftKey ? -1 : 1);
|
|
}
|
|
};
|
|
element.addEventListener("keydown", onDesktopKeydown);
|
|
cleanup.push(() => element.removeEventListener("keydown", onDesktopKeydown));
|
|
}
|
|
function endDrag(cancelled) {
|
|
if (drag) drag.finish(cancelled);
|
|
}
|
|
function attachWindow(id, windowElement, windowOptions = {}) {
|
|
const win = wm.get(id);
|
|
if (!win) throw new Error(`wmkit: cannot attach unknown window "${id}"`);
|
|
if (registry.has(id)) throw new Error(`wmkit: window "${id}" is already attached`);
|
|
const attached = {
|
|
element: windowElement,
|
|
handle: null,
|
|
handles: [],
|
|
lastState: null,
|
|
lastZ: -1,
|
|
cleanup: []
|
|
};
|
|
windowElement.dataset.wmWindow = id;
|
|
windowElement.setAttribute("role", "dialog");
|
|
windowElement.tabIndex = -1;
|
|
windowElement.style.position = "absolute";
|
|
windowElement.style.left = "0";
|
|
windowElement.style.top = "0";
|
|
const titleEl = windowElement.querySelector("[data-wm-title]");
|
|
if (titleEl) {
|
|
if (!titleEl.id) titleEl.id = `wmkit-title-${id}`;
|
|
windowElement.setAttribute("aria-labelledby", titleEl.id);
|
|
}
|
|
const handle = typeof windowOptions.handle === "string" ? windowElement.querySelector(windowOptions.handle) : windowOptions.handle ?? windowElement.querySelector("[data-wm-drag]");
|
|
attached.handle = handle;
|
|
const onPointerDownFocus = () => {
|
|
wm.focus(id);
|
|
};
|
|
windowElement.addEventListener("pointerdown", onPointerDownFocus, true);
|
|
attached.cleanup.push(
|
|
() => windowElement.removeEventListener("pointerdown", onPointerDownFocus, true)
|
|
);
|
|
const onClick = (event) => {
|
|
const target = event.target;
|
|
if (!target) return;
|
|
const current = wm.get(id);
|
|
if (!current) return;
|
|
if (target.closest("[data-wm-close]")) {
|
|
if (current.closable) wm.close(id);
|
|
} else if (target.closest("[data-wm-minimize]")) {
|
|
if (current.minimizable) wm.minimize(id);
|
|
} else if (target.closest("[data-wm-maximize]")) {
|
|
if (current.maximizable) wm.toggleMaximize(id);
|
|
}
|
|
};
|
|
windowElement.addEventListener("click", onClick);
|
|
attached.cleanup.push(() => windowElement.removeEventListener("click", onClick));
|
|
if (handle) {
|
|
handle.style.touchAction = "none";
|
|
const onHandleDown = (event) => startDrag(id, handle, event);
|
|
handle.addEventListener("pointerdown", onHandleDown);
|
|
attached.cleanup.push(() => handle.removeEventListener("pointerdown", onHandleDown));
|
|
const onDoubleClick = (event) => {
|
|
const target = event.target;
|
|
if (target?.closest(INTERACTIVE_SELECTOR)) return;
|
|
const current = wm.get(id);
|
|
if (current?.maximizable) wm.toggleMaximize(id);
|
|
};
|
|
handle.addEventListener("dblclick", onDoubleClick);
|
|
attached.cleanup.push(() => handle.removeEventListener("dblclick", onDoubleClick));
|
|
if (options.onTitlebarContextMenu) {
|
|
const onContextMenu = (event) => {
|
|
const current = wm.get(id);
|
|
if (!current) return;
|
|
event.preventDefault();
|
|
options.onTitlebarContextMenu?.(current, event);
|
|
};
|
|
handle.addEventListener("contextmenu", onContextMenu);
|
|
attached.cleanup.push(() => handle.removeEventListener("contextmenu", onContextMenu));
|
|
}
|
|
}
|
|
if (windowOptions.resizeHandles !== false) {
|
|
for (const { element: resizeHandle, direction } of createResizeHandles(
|
|
doc,
|
|
hitEdge,
|
|
hitCorner
|
|
)) {
|
|
const onResizeDown = (event) => startResize(id, direction, event);
|
|
resizeHandle.addEventListener("pointerdown", onResizeDown);
|
|
attached.cleanup.push(() => resizeHandle.removeEventListener("pointerdown", onResizeDown));
|
|
windowElement.append(resizeHandle);
|
|
attached.handles.push(resizeHandle);
|
|
}
|
|
}
|
|
const onWindowKeydown = (event) => {
|
|
const target = event.target;
|
|
if (target?.closest(INTERACTIVE_SELECTOR)) return;
|
|
const current = wm.get(id);
|
|
if (!current) return;
|
|
const arrows = {
|
|
ArrowLeft: [-1, 0],
|
|
ArrowRight: [1, 0],
|
|
ArrowUp: [0, -1],
|
|
ArrowDown: [0, 1]
|
|
};
|
|
const vector = arrows[event.key];
|
|
if (!vector || !keyboardEnabled) return;
|
|
event.preventDefault();
|
|
const step = event.altKey ? 1 : moveStep;
|
|
const [dx, dy] = vector;
|
|
if (event.shiftKey) {
|
|
if (current.resizable) {
|
|
wm.resize(id, {
|
|
width: current.bounds.width + dx * step,
|
|
height: current.bounds.height + dy * step
|
|
});
|
|
}
|
|
} else if (current.draggable && current.stage === "normal") {
|
|
wm.moveBy(id, dx * step, dy * step);
|
|
}
|
|
};
|
|
windowElement.addEventListener("keydown", onWindowKeydown);
|
|
attached.cleanup.push(() => windowElement.removeEventListener("keydown", onWindowKeydown));
|
|
const onModalTrap = (event) => {
|
|
if (event.key !== "Tab") return;
|
|
const current = wm.get(id);
|
|
if (current?.layer !== "modal") return;
|
|
const focusables = windowElement.querySelectorAll(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
if (focusables.length === 0) return;
|
|
const first = focusables[0];
|
|
const last = focusables[focusables.length - 1];
|
|
if (!first || !last) return;
|
|
if (event.shiftKey && doc.activeElement === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && doc.activeElement === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
windowElement.addEventListener("keydown", onModalTrap);
|
|
attached.cleanup.push(() => windowElement.removeEventListener("keydown", onModalTrap));
|
|
const detach = () => {
|
|
if (drag?.id === id) endDrag(true);
|
|
for (const dispose of attached.cleanup) dispose();
|
|
for (const resizeHandle of attached.handles) resizeHandle.remove();
|
|
registry.delete(id);
|
|
};
|
|
if (windowOptions.removeOnClose) {
|
|
const stopOnClose = wm.on("close", ({ window: closed }) => {
|
|
if (closed.id !== id) return;
|
|
detach();
|
|
windowElement.remove();
|
|
});
|
|
attached.cleanup.push(stopOnClose);
|
|
}
|
|
registry.set(id, attached);
|
|
lastOrder = null;
|
|
lastFocused = null;
|
|
syncAll();
|
|
return detach;
|
|
}
|
|
return {
|
|
element,
|
|
wm,
|
|
attachWindow,
|
|
destroy() {
|
|
endDrag(true);
|
|
for (const [, attached] of registry) {
|
|
for (const dispose of attached.cleanup) dispose();
|
|
for (const resizeHandle of attached.handles) resizeHandle.remove();
|
|
}
|
|
registry.clear();
|
|
for (const dispose of cleanup) dispose();
|
|
preview?.remove();
|
|
delete element.dataset.wmDesktop;
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/dom/binder.ts
|
|
function createDesktopBinder(wm, options = {}) {
|
|
let controller = null;
|
|
const entries = /* @__PURE__ */ new Set();
|
|
function attachEntry(entry) {
|
|
if (controller && !entry.detach && wm.get(entry.id)) {
|
|
entry.detach = controller.attachWindow(entry.id, entry.element, entry.options);
|
|
}
|
|
}
|
|
wm.on("open", ({ window: win }) => {
|
|
for (const entry of entries) {
|
|
if (entry.id === win.id) attachEntry(entry);
|
|
}
|
|
});
|
|
return {
|
|
wm,
|
|
controller: () => controller,
|
|
bindDesktop(element) {
|
|
if (controller) throw new Error("wmkit: desktop is already bound");
|
|
controller = attachDesktop(wm, element, options);
|
|
for (const entry of entries) attachEntry(entry);
|
|
return () => {
|
|
for (const entry of entries) entry.detach = null;
|
|
controller?.destroy();
|
|
controller = null;
|
|
};
|
|
},
|
|
bindWindow(id, element, windowOptions) {
|
|
const entry = { id, element, options: windowOptions, detach: null };
|
|
entries.add(entry);
|
|
attachEntry(entry);
|
|
return () => {
|
|
entry.detach?.();
|
|
entry.detach = null;
|
|
entries.delete(entry);
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
export { attachDesktop, boundsEqual, clamp, clampSize, clampToViewport, createAnnouncer, createDesktopBinder, createEmitter, createWindowManager, defaultMessages, detectSnapZone, flipToTarget, magnetize, prefersReducedMotion, zoneBounds };
|
|
//# sourceMappingURL=chunk-YCHJXSTC.js.map
|
|
//# sourceMappingURL=chunk-YCHJXSTC.js.map
|