@songsid/agend 2.3.1 → 2.3.2-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +16 -10
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.d.ts +7 -1
- package/dist/authority/session-authorizations.js +6 -4
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +19 -6
- package/dist/authority/web-authority.js +22 -8
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +33 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -5
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +11 -0
- package/dist/config-validator.js +40 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +34 -3
- package/dist/daemon.js +227 -46
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +12 -1
- package/dist/delivery-outbox.js +67 -15
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +9 -0
- package/dist/doctor.js.map +1 -1
- package/dist/event-log.d.ts +5 -0
- package/dist/event-log.js +13 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +140 -0
- package/dist/fleet-manager.js +414 -29
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +1 -0
- package/dist/fleet-process-handlers.js +9 -1
- package/dist/fleet-process-handlers.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +113 -55
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +114 -56
- package/dist/guest-seen-people.d.ts +38 -0
- package/dist/guest-seen-people.js +78 -0
- package/dist/guest-seen-people.js.map +1 -0
- package/dist/instance-config-impact.js +4 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -0
- package/dist/instance-lifecycle.js +16 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +38 -10
- package/dist/locale.js.map +1 -1
- package/dist/mac-auto-restore.d.ts +117 -0
- package/dist/mac-auto-restore.js +294 -0
- package/dist/mac-auto-restore.js.map +1 -0
- package/dist/outbound-schemas.d.ts +2 -0
- package/dist/outbound-schemas.js +2 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +9 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/settings-api.d.ts +26 -0
- package/dist/settings-api.js +107 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +37 -3
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +5 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/start-attempt.d.ts +9 -5
- package/dist/start-attempt.js +27 -8
- package/dist/start-attempt.js.map +1 -1
- package/dist/start-failure-chat.d.ts +4 -0
- package/dist/start-failure-chat.js +23 -6
- package/dist/start-failure-chat.js.map +1 -1
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +8 -0
- package/dist/start-failure-notice.js.map +1 -1
- package/dist/start-failure.d.ts +25 -0
- package/dist/start-failure.js +75 -2
- package/dist/start-failure.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +25 -0
- package/dist/turn-reply-guard.js +111 -12
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/ui/backend-signin.js +5 -2
- package/dist/ui/chat-ambient.js +1 -1
- package/dist/ui/chat-effects.js +4 -4
- package/dist/ui/chat-render.js +5 -2
- package/dist/ui/chat-strings.js +2 -2
- package/dist/ui/chat-thread.js +2 -2
- package/dist/ui/first-run.js +2 -2
- package/dist/ui/fleet-canvas-view.js +114 -0
- package/dist/ui/fleet-canvas.js +15 -77
- package/dist/ui/fleet-office-editor.js +80 -5
- package/dist/ui/fleet-office-layout.js +197 -0
- package/dist/ui/fleet-office-model.js +6 -3
- package/dist/ui/fleet-office-overview.js +167 -0
- package/dist/ui/fleet-office-pack.d.ts +39 -0
- package/dist/ui/fleet-office-pack.js +240 -29
- package/dist/ui/fleet-office-skin.js +140 -29
- package/dist/ui/fleet-office-store.js +84 -5
- package/dist/ui/fleet-office-view.js +154 -0
- package/dist/ui/fleet-office-zones.js +95 -33
- package/dist/ui/fleet-office.js +779 -140
- package/dist/ui/fleet-usage.js +7 -6
- package/dist/ui/panel-chat.js +1 -1
- package/dist/ui/panel-details.js +2 -2
- package/dist/ui/panel-fleet.js +11 -77
- package/dist/ui/panel-log.js +2 -2
- package/dist/ui/panel-settings.js +578 -163
- package/dist/ui/search-dialog.js +26 -7
- package/dist/ui/settings-appearance.js +82 -29
- package/dist/ui/settings-apply.js +42 -7
- package/dist/ui/settings-dialogs.js +68 -22
- package/dist/ui/settings-draft.js +51 -0
- package/dist/ui/settings-guests.js +244 -78
- package/dist/ui/settings-learn.js +2 -2
- package/dist/ui/settings-model.js +2 -0
- package/dist/ui/settings-registry.js +142 -0
- package/dist/ui/settings-strings.js +102 -44
- package/dist/ui/settings-web-authority.js +34 -23
- package/dist/ui/shared/app-i18n.js +14 -0
- package/dist/ui/shared/app-nav.js +10 -5
- package/dist/ui/shared/app-prefs.js +80 -2
- package/dist/ui/shared/app-route.js +39 -5
- package/dist/ui/shared/app-shell.js +59 -4
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +254 -17
- package/dist/ui/shared/app.js +3 -0
- package/dist/ui/shared/effect-stock-trust.js +1 -1
- package/dist/ui/shared/identity-color.js +4 -4
- package/dist/ui/shared/signin.js +2 -2
- package/dist/ui/shared/ui-dialog.js +6 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/web-authorize.js +13 -12
- package/dist/web-api.d.ts +8 -2
- package/dist/web-api.js +160 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +7 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-experimental.d.ts +1 -1
- package/dist/web-experimental.js +1 -1
- package/dist/web-experimental.js.map +1 -1
- package/dist/web-mod-agent.js +2 -2
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-office-skins.d.ts +108 -0
- package/dist/web-office-skins.js +298 -0
- package/dist/web-office-skins.js.map +1 -0
- package/dist/web-office-store.d.ts +8 -2
- package/dist/web-office-store.js +37 -7
- package/dist/web-office-store.js.map +1 -1
- package/dist/web-settings-patch.d.ts +6 -0
- package/dist/web-settings-patch.js +61 -4
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -2
- package/dist/web-shell-routes.js +17 -7
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/working-recovery.d.ts +104 -0
- package/dist/working-recovery.js +263 -0
- package/dist/working-recovery.js.map +1 -0
- package/dist/working-state-journal.d.ts +3 -1
- package/dist/working-state-journal.js +15 -2
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/first-run.js
CHANGED
|
@@ -5,7 +5,7 @@ import { html } from "/assets/app-html.js";
|
|
|
5
5
|
import { register, t } from "/assets/app-i18n.js";
|
|
6
6
|
import { appStore, createStore, useStore } from "/assets/app-store.js";
|
|
7
7
|
import { navigate } from "/assets/app-nav.js";
|
|
8
|
-
import {
|
|
8
|
+
import { sectionPath } from "/assets/app-route.js";
|
|
9
9
|
import { Icon } from "/assets/ui-icons.js";
|
|
10
10
|
|
|
11
11
|
register("firstRun", {
|
|
@@ -52,7 +52,7 @@ export function useFirstRun() {
|
|
|
52
52
|
export function FirstRunCard({ onConnect }) {
|
|
53
53
|
const shown = useFirstRun();
|
|
54
54
|
if (!shown) return null;
|
|
55
|
-
const connect = onConnect || (() => { local.set({ wizard: true }); navigate(
|
|
55
|
+
const connect = onConnect || (() => { local.set({ wizard: true }); navigate(sectionPath("bots")); });
|
|
56
56
|
return html`<section class="card first-run" aria-labelledby="first-run-title">
|
|
57
57
|
<h3 id="first-run-title"><${Icon} name="plug" size=${14} /> ${t("firstRun.title")}</h3>
|
|
58
58
|
<p>${t("firstRun.body")}</p>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The Fleet canvases' shared view (#1918's org chart canvas, fleet-canvas.js; #2540's Office, fleet-office.js): the pure
|
|
2
|
+
// pan and zoom math and the pointer gestures, so both feel the same. A view is { k, x, y }: a world point (wx, wy) is
|
|
3
|
+
// drawn at (x + wx·k, y + wy·k) in the viewport's CSS px.
|
|
4
|
+
//
|
|
5
|
+
// - One pointer pans once it has moved a few px (so a tap still opens what it is on); two pinch: the world point under
|
|
6
|
+
// their old midpoint goes to the new one, scaled by how their span changed (a two-finger move pans too). The pointer
|
|
7
|
+
// is captured only once it drags: a capture at once would send the tap's click to the viewport, not what was tapped.
|
|
8
|
+
// The click that ends a drag or a pinch opens nothing.
|
|
9
|
+
// - The wheel zooms about the pointer; + / − / 0 zoom in, out and fit (with no Alt, Ctrl or Meta held).
|
|
10
|
+
|
|
11
|
+
export const K_MIN = 0.2, K_MAX = 2.5;
|
|
12
|
+
/** `k` kept in [lo, hi] (the org canvas's range by default). */
|
|
13
|
+
export const clampK = (k, lo = K_MIN, hi = K_MAX) => Math.min(hi, Math.max(lo, k));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The view that shows region `r` in a `size` viewport: as large as fits (at most `maxK`), centred across, from the top.
|
|
17
|
+
* `minK`: never smaller than this (a phone's opening); a region that is then wider than the viewport starts at its left.
|
|
18
|
+
* `widthOnly`: fit the width, let the height run on (the opening: the rest is below).
|
|
19
|
+
*/
|
|
20
|
+
export function fitView(r, size, { margin = 24, minK = K_MIN, maxK = 1.25, widthOnly = false } = {}) {
|
|
21
|
+
const across = (size.w - 2 * margin) / r.w, down = widthOnly ? Infinity : (size.h - 2 * margin) / r.h;
|
|
22
|
+
const k = clampK(Math.max(minK, Math.min(across, down, maxK)));
|
|
23
|
+
const x = r.w * k > size.w - 2 * margin ? margin - r.x * k : size.w / 2 - (r.x + r.w / 2) * k;
|
|
24
|
+
return { k, x, y: margin - r.y * k };
|
|
25
|
+
}
|
|
26
|
+
/** The view at scale `k` with the viewport point (cx, cy) on the same world point as in `v`. */
|
|
27
|
+
export function zoomTo(v, k, cx, cy) {
|
|
28
|
+
return { k, x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k) };
|
|
29
|
+
}
|
|
30
|
+
/** Zoom by `f` about the viewport point (cx, cy): the canvas point under it stays under it. */
|
|
31
|
+
export function zoomAt(v, f, cx, cy) { return zoomTo(v, clampK(v.k * f), cx, cy); }
|
|
32
|
+
/** A pinch step at scale `k`: the world point under the old midpoint goes to the new midpoint. */
|
|
33
|
+
export function pinchTo(v, from, to, k) {
|
|
34
|
+
const wx = (from.mid.x - v.x) / v.k, wy = (from.mid.y - v.y) / v.k;
|
|
35
|
+
return { k, x: to.mid.x - wx * k, y: to.mid.y - wy * k };
|
|
36
|
+
}
|
|
37
|
+
/** A pinch step: the canvas point under the old midpoint goes to the new midpoint, the zoom scaled by the spans' ratio. */
|
|
38
|
+
export function pinchView(v, from, to) { return pinchTo(v, from, to, clampK(v.k * (to.dist / from.dist))); }
|
|
39
|
+
/** Viewport CSS px → world units, and back. */
|
|
40
|
+
export const toWorld = (v, sx, sy) => ({ x: (sx - v.x) / v.k, y: (sy - v.y) / v.k });
|
|
41
|
+
export const toScreen = (v, wx, wy) => ({ x: v.x + wx * v.k, y: v.y + wy * v.k });
|
|
42
|
+
/** The view at the same scale with world point (wx, wy) in the middle of a `size` viewport. */
|
|
43
|
+
export const centreOn = (v, size, wx, wy) => ({ k: v.k, x: size.w / 2 - wx * v.k, y: size.h / 2 - wy * v.k });
|
|
44
|
+
/**
|
|
45
|
+
* The world point a click on an overview at `rect` (its client box) shows, for an overview of world box `box` drawn
|
|
46
|
+
* whole and centred in it (an SVG viewBox's xMidYMid meet); null when it has no size.
|
|
47
|
+
*/
|
|
48
|
+
export function miniPoint(rect, box, clientX, clientY) {
|
|
49
|
+
if (!rect || !rect.width || !rect.height) return null;
|
|
50
|
+
const scale = Math.min(rect.width / box.w, rect.height / box.h);
|
|
51
|
+
return { x: box.x + (clientX - rect.left - (rect.width - box.w * scale) / 2) / scale, y: box.y + (clientY - rect.top - (rect.height - box.h * scale) / 2) / scale };
|
|
52
|
+
}
|
|
53
|
+
/** The wheel's zoom factor (a notch is about 15%; a trackpad's small deltas zoom a little). */
|
|
54
|
+
export const wheelFactor = (e) => Math.exp(-e.deltaY * 0.0015);
|
|
55
|
+
/** What a key does to the view: "in", "out", "fit" (0), or null (any other key, or with Alt, Ctrl or Meta). */
|
|
56
|
+
export function zoomKey(e) {
|
|
57
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return null;
|
|
58
|
+
if (e.key === "+" || e.key === "=") return "in";
|
|
59
|
+
if (e.key === "-" || e.key === "_") return "out";
|
|
60
|
+
if (e.key === "0") return "fit";
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── The pointer gestures: the state lives in the caller's ref (it outlives a render), the handlers are made per render ──
|
|
65
|
+
|
|
66
|
+
export const SLOP = 5;
|
|
67
|
+
/** A fresh gesture state: the pointers down, how far the one has moved, the pinch's last span, whether to swallow the click. */
|
|
68
|
+
export const gestureState = () => ({ pts: new Map(), moved: 0, span: null, swallow: false });
|
|
69
|
+
/**
|
|
70
|
+
* The handlers for state `d`: `el()` the element measured and captured; `pan(dx, dy)` and `pinch(from, to)` move the
|
|
71
|
+
* view. `move` returns whether it was a drag or a pinch (not a hover). `busy()`: a drag or pinch is under way.
|
|
72
|
+
*/
|
|
73
|
+
export function gestures(d, { el, pan, pinch }) {
|
|
74
|
+
const spanOf = () => {
|
|
75
|
+
const [a, b] = [...d.pts.values()], r = el().getBoundingClientRect();
|
|
76
|
+
return { mid: { x: (a.x + b.x) / 2 - r.left, y: (a.y + b.y) / 2 - r.top }, dist: Math.hypot(a.x - b.x, a.y - b.y) };
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
down(e) {
|
|
80
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
81
|
+
d.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
82
|
+
if (d.pts.size === 1) { d.moved = 0; d.swallow = false; d.span = null; }
|
|
83
|
+
else if (d.pts.size === 2) { d.swallow = true; d.span = spanOf(); }
|
|
84
|
+
},
|
|
85
|
+
move(e) {
|
|
86
|
+
const prev = d.pts.get(e.pointerId);
|
|
87
|
+
if (!prev) return false;
|
|
88
|
+
d.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
89
|
+
if (d.pts.size === 1) {
|
|
90
|
+
d.moved += Math.abs(e.clientX - prev.x) + Math.abs(e.clientY - prev.y);
|
|
91
|
+
if (d.moved < SLOP) return false;
|
|
92
|
+
if (!d.swallow) { d.swallow = true; try { el().setPointerCapture(e.pointerId); } catch { /* gone already */ } }
|
|
93
|
+
pan(e.clientX - prev.x, e.clientY - prev.y);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (d.pts.size !== 2) return true;
|
|
97
|
+
d.swallow = true;
|
|
98
|
+
const next = spanOf();
|
|
99
|
+
if (d.span && d.span.dist > 0 && next.dist > 0) pinch(d.span, next);
|
|
100
|
+
d.span = next;
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
up(e) {
|
|
104
|
+
d.pts.delete(e.pointerId);
|
|
105
|
+
// Back to one finger: it pans on at once (the pinch already swallows the click); two again: a new span.
|
|
106
|
+
d.span = d.pts.size === 2 ? spanOf() : null;
|
|
107
|
+
if (d.pts.size === 1) d.moved = SLOP;
|
|
108
|
+
// The click that ends a drag comes after this: let it be swallowed, then forget.
|
|
109
|
+
if (d.swallow && !d.pts.size) setTimeout(() => { if (!d.pts.size) d.swallow = false; }, 0);
|
|
110
|
+
},
|
|
111
|
+
clickCapture(e) { if (d.swallow) { e.preventDefault(); e.stopPropagation(); } },
|
|
112
|
+
busy: () => d.swallow && d.pts.size > 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
package/dist/ui/fleet-canvas.js
CHANGED
|
@@ -32,6 +32,7 @@ import { Office } from "./fleet-office.js";
|
|
|
32
32
|
import { OC, REST_KEY, SUM_LINE, canvasLayout, hits, needle, openingRegion, summaryRows } from "./fleet-canvas-layout.js";
|
|
33
33
|
import { createTrafficStore, FAR_K, HEAD, POLL_MS, sendingBy, trafficEdges } from "./fleet-traffic.js";
|
|
34
34
|
import { routeScene } from "./fleet-route-scene.js";
|
|
35
|
+
import { K_MAX, K_MIN, fitView, gestureState, gestures, miniPoint, pinchView, wheelFactor, zoomAt, zoomKey } from "./fleet-canvas-view.js";
|
|
35
36
|
import { createRouteClient } from "./fleet-route.js";
|
|
36
37
|
|
|
37
38
|
register("orgc", {
|
|
@@ -62,32 +63,9 @@ const tc = (k, ...v) => t(`orgc.${k}`, ...v);
|
|
|
62
63
|
|
|
63
64
|
export { OC, REST_KEY, SUM_LINE, canvasLayout, openingRegion, summaryRows };
|
|
64
65
|
|
|
65
|
-
// ── The view: pan, zoom, fit (
|
|
66
|
+
// ── The view: pan, zoom, fit and the gestures (fleet-canvas-view.js, shared with the Office view) ──
|
|
66
67
|
|
|
67
|
-
export
|
|
68
|
-
const clampK = (k) => Math.min(K_MAX, Math.max(K_MIN, k));
|
|
69
|
-
/**
|
|
70
|
-
* The view that shows region `r` in a `size` viewport: as large as fits (at most `maxK`), centred across, from the top.
|
|
71
|
-
* `minK`: never smaller than this (a phone's opening); a region that is then wider than the viewport starts at its left.
|
|
72
|
-
* `widthOnly`: fit the width, let the height run on (the opening: the rest is below).
|
|
73
|
-
*/
|
|
74
|
-
export function fitView(r, size, { margin = 24, minK = K_MIN, maxK = 1.25, widthOnly = false } = {}) {
|
|
75
|
-
const across = (size.w - 2 * margin) / r.w, down = widthOnly ? Infinity : (size.h - 2 * margin) / r.h;
|
|
76
|
-
const k = clampK(Math.max(minK, Math.min(across, down, maxK)));
|
|
77
|
-
const x = r.w * k > size.w - 2 * margin ? margin - r.x * k : size.w / 2 - (r.x + r.w / 2) * k;
|
|
78
|
-
return { k, x, y: margin - r.y * k };
|
|
79
|
-
}
|
|
80
|
-
/** A pinch step: the canvas point under the old midpoint goes to the new midpoint, the zoom scaled by the spans' ratio. */
|
|
81
|
-
export function pinchView(v, from, to) {
|
|
82
|
-
const k = clampK(v.k * (to.dist / from.dist));
|
|
83
|
-
const wx = (from.mid.x - v.x) / v.k, wy = (from.mid.y - v.y) / v.k;
|
|
84
|
-
return { k, x: to.mid.x - wx * k, y: to.mid.y - wy * k };
|
|
85
|
-
}
|
|
86
|
-
/** Zoom by `f` about the viewport point (cx, cy): the canvas point under it stays under it. */
|
|
87
|
-
export function zoomAt(v, f, cx, cy) {
|
|
88
|
-
const k = clampK(v.k * f);
|
|
89
|
-
return { k, x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k) };
|
|
90
|
-
}
|
|
68
|
+
export { K_MIN, K_MAX, fitView, pinchView, zoomAt };
|
|
91
69
|
|
|
92
70
|
// ── Per device ──
|
|
93
71
|
|
|
@@ -316,7 +294,7 @@ function Canvas({ view, folded, onFold, q, jump, traffic }) {
|
|
|
316
294
|
const L = useMemo(() => canvasLayout(view, folded, { narrow, label: (s, n) => `${n} ${STATE_LABEL(s)}` }), [view, folded, narrow]);
|
|
317
295
|
const svgRef = useRef(null), worldRef = useRef(null), miniRef = useRef(null), miniViewRef = useRef(null);
|
|
318
296
|
const vp = useRef(null);
|
|
319
|
-
const drag = useRef(
|
|
297
|
+
const drag = useRef(gestureState());
|
|
320
298
|
const size = () => {
|
|
321
299
|
const r = svgRef.current ? svgRef.current.getBoundingClientRect() : null;
|
|
322
300
|
return r && r.width > 0 && r.height > 0 ? { w: r.width, h: r.height } : { w: 960, h: 600 };
|
|
@@ -364,58 +342,20 @@ function Canvas({ view, folded, onFold, q, jump, traffic }) {
|
|
|
364
342
|
});
|
|
365
343
|
|
|
366
344
|
const open = openChat;
|
|
367
|
-
// The click that ends a drag or a pinch opens nothing and folds nothing.
|
|
368
|
-
const
|
|
345
|
+
// The click that ends a drag or a pinch opens nothing and folds nothing (fleet-canvas-view.js gestures).
|
|
346
|
+
const g = gestures(drag.current, { el: () => svgRef.current, pan: (dx, dy) => set({ ...vp.current, x: vp.current.x + dx, y: vp.current.y + dy }),
|
|
347
|
+
pinch: (from, to) => set(pinchView(vp.current, from, to)) });
|
|
348
|
+
const onClickCapture = g.clickCapture;
|
|
369
349
|
const onWheel = (e) => {
|
|
370
350
|
e.preventDefault();
|
|
371
351
|
const r = svgRef.current.getBoundingClientRect();
|
|
372
|
-
set(zoomAt(vp.current,
|
|
373
|
-
};
|
|
374
|
-
// One pointer pans (after a few pixels, so a tap still opens a card); two pinch: the canvas point under their old
|
|
375
|
-
// midpoint goes to the new one, scaled by how their span changed (so a two-finger move pans too). The pointer is
|
|
376
|
-
// captured only once it drags: a capture at once would send the tap's click to the canvas, not the card.
|
|
377
|
-
const spanOf = () => {
|
|
378
|
-
const [a, b] = [...drag.current.pts.values()], r = svgRef.current.getBoundingClientRect();
|
|
379
|
-
return { mid: { x: (a.x + b.x) / 2 - r.left, y: (a.y + b.y) / 2 - r.top }, dist: Math.hypot(a.x - b.x, a.y - b.y) };
|
|
380
|
-
};
|
|
381
|
-
const onPointerDown = (e) => {
|
|
382
|
-
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
383
|
-
const d = drag.current;
|
|
384
|
-
d.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
385
|
-
if (d.pts.size === 1) { d.moved = 0; d.swallow = false; d.span = null; }
|
|
386
|
-
else if (d.pts.size === 2) { d.swallow = true; d.span = spanOf(); }
|
|
387
|
-
};
|
|
388
|
-
const onPointerMove = (e) => {
|
|
389
|
-
const d = drag.current, prev = d.pts.get(e.pointerId);
|
|
390
|
-
if (!prev) return;
|
|
391
|
-
d.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
392
|
-
if (d.pts.size === 1) {
|
|
393
|
-
d.moved += Math.abs(e.clientX - prev.x) + Math.abs(e.clientY - prev.y);
|
|
394
|
-
if (d.moved < 5) return;
|
|
395
|
-
if (!d.swallow) { d.swallow = true; try { svgRef.current.setPointerCapture(e.pointerId); } catch { /* gone already */ } }
|
|
396
|
-
set({ ...vp.current, x: vp.current.x + e.clientX - prev.x, y: vp.current.y + e.clientY - prev.y });
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
if (d.pts.size !== 2) return;
|
|
400
|
-
d.swallow = true;
|
|
401
|
-
const next = spanOf();
|
|
402
|
-
if (d.span && d.span.dist > 0 && next.dist > 0) set(pinchView(vp.current, d.span, next));
|
|
403
|
-
d.span = next;
|
|
404
|
-
};
|
|
405
|
-
const onPointerUp = (e) => {
|
|
406
|
-
const d = drag.current;
|
|
407
|
-
d.pts.delete(e.pointerId);
|
|
408
|
-
// Back to one finger: it pans on at once (the pinch already swallows the click); two again: a new span.
|
|
409
|
-
d.span = d.pts.size === 2 ? spanOf() : null;
|
|
410
|
-
if (d.pts.size === 1) d.moved = 5;
|
|
411
|
-
// The click that ends a drag comes after this: let it be swallowed, then forget.
|
|
412
|
-
if (d.swallow && !d.pts.size) setTimeout(() => { if (!d.pts.size) d.swallow = false; }, 0);
|
|
352
|
+
set(zoomAt(vp.current, wheelFactor(e), e.clientX - r.left, e.clientY - r.top));
|
|
413
353
|
};
|
|
354
|
+
const onPointerDown = g.down, onPointerMove = g.move, onPointerUp = g.up;
|
|
414
355
|
const onKeyDown = (e) => {
|
|
415
356
|
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
416
|
-
|
|
417
|
-
if (e.
|
|
418
|
-
if (e.key === "0") { e.preventDefault(); fit(); return; }
|
|
357
|
+
const z = zoomKey(e);
|
|
358
|
+
if (z) { e.preventDefault(); if (z === "fit") fit(); else zoomBy(z === "in" ? 1.25 : 0.8); return; }
|
|
419
359
|
const dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
|
|
420
360
|
const card = e.target && e.target.closest ? e.target.closest(".oc-node") : null;
|
|
421
361
|
if (card && (e.key === "Enter" || e.key === " ")) {
|
|
@@ -436,12 +376,10 @@ function Canvas({ view, folded, onFold, q, jump, traffic }) {
|
|
|
436
376
|
if (c) show(c, false);
|
|
437
377
|
};
|
|
438
378
|
const onMini = (e) => {
|
|
439
|
-
const
|
|
440
|
-
if (!
|
|
441
|
-
const scale = Math.min(r.width / box.w, r.height / box.h);
|
|
442
|
-
const wx = box.x + (e.clientX - r.left - (r.width - box.w * scale) / 2) / scale, wy = box.y + (e.clientY - r.top - (r.height - box.h * scale) / 2) / scale;
|
|
379
|
+
const w = miniPoint(miniRef.current.getBoundingClientRect(), miniBox(L), e.clientX, e.clientY);
|
|
380
|
+
if (!w) return;
|
|
443
381
|
const s = size(), v = vp.current;
|
|
444
|
-
set({ k: v.k, x: s.w / 2 -
|
|
382
|
+
set({ k: v.k, x: s.w / 2 - w.x * v.k, y: s.h / 2 - w.y * v.k });
|
|
445
383
|
};
|
|
446
384
|
|
|
447
385
|
const box = miniBox(L);
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// editor). An admin session adds an office, renames it, chooses its style (Desks / Studio), picks who is in it (whole
|
|
3
3
|
// teams, or one instance at a time; none: everyone) and deletes one — then saves the whole list with the rev it read
|
|
4
4
|
// (PUT /ui/offices; a change made meanwhile is a 409: read again, nothing overwritten). A stored office's layout (seats,
|
|
5
|
-
// paths)
|
|
6
|
-
//
|
|
5
|
+
// paths) and its layout overrides (Edit layout, fleet-office-layout.js) are kept as they are. Skin packs (#2513): the
|
|
6
|
+
// packs the host keeps are styles to choose; an admin uploads one here (its manifest.json and its PNGs, picked together
|
|
7
|
+
// or as a folder — sent at once, checked by the host, which keeps it whole) or deletes one. Never offered to a guest,
|
|
8
|
+
// the public link or a read-only session (the host refuses them too).
|
|
7
9
|
import { html, useMemo, useState } from "/assets/app-html.js";
|
|
8
10
|
import { t, register } from "/assets/app-i18n.js";
|
|
9
11
|
import { Dialog } from "/assets/ui-dialog.js";
|
|
@@ -22,6 +24,10 @@ register("orge", {
|
|
|
22
24
|
rateLimited: "Too many saves. Try again in a minute.",
|
|
23
25
|
failed: "The offices were not saved ({0}).", layoutKept: "Its seats and paths are kept.",
|
|
24
26
|
styleUnknown: "{0} (not available here: drawn as Desks)",
|
|
27
|
+
packs: "Skin packs", packsLead: "Styles drawn from an artist's pictures. Pick a pack's manifest.json and all its PNG files together (or its folder).",
|
|
28
|
+
packsNone: "No skin packs yet.", upload: "Upload a skin pack", uploadFolder: "Upload a folder", uploading: "Uploading…", uploaded: "Skin pack “{0}” uploaded.",
|
|
29
|
+
uploadFailed: "The skin pack was not uploaded: {0}", noManifest: "no manifest.json among the files", badManifest: "manifest.json is not JSON",
|
|
30
|
+
removePack: "Delete {0}", removedPack: "Skin pack “{0}” deleted.", removeFailed: "The skin pack was not deleted ({0}).",
|
|
25
31
|
},
|
|
26
32
|
"zh-TW": {
|
|
27
33
|
edit: "編輯辦公室", title: "辦公室", lead: "這個 fleet 的每個人在辦公室檢視中都會看到這些辦公室。沒有選任何成員的辦公室會顯示全部。",
|
|
@@ -34,12 +40,79 @@ register("orge", {
|
|
|
34
40
|
rateLimited: "儲存太頻繁,請一分鐘後再試。",
|
|
35
41
|
failed: "辦公室沒有儲存({0})。", layoutKept: "它的座位與路徑會保留。",
|
|
36
42
|
styleUnknown: "{0}(這裡沒有:以辦公桌顯示)",
|
|
43
|
+
packs: "外觀包", packsLead: "用畫師的圖片畫出的風格。請把外觀包的 manifest.json 和所有 PNG 檔一起選取(或選整個資料夾)。",
|
|
44
|
+
packsNone: "還沒有外觀包。", upload: "上傳外觀包", uploadFolder: "上傳資料夾", uploading: "上傳中…", uploaded: "已上傳外觀包「{0}」。",
|
|
45
|
+
uploadFailed: "外觀包沒有上傳:{0}", noManifest: "檔案中沒有 manifest.json", badManifest: "manifest.json 不是 JSON",
|
|
46
|
+
removePack: "刪除 {0}", removedPack: "已刪除外觀包「{0}」。", removeFailed: "外觀包沒有刪除({0})。",
|
|
37
47
|
},
|
|
38
48
|
});
|
|
39
49
|
const te = (k, ...v) => t(`orge.${k}`, ...v);
|
|
40
50
|
export const MAX_OFFICES = 20;
|
|
41
51
|
const STYLES = ["pixel", "studio"];
|
|
42
52
|
|
|
53
|
+
/** A file's bytes as base64 (in slices: a large picture never goes through one huge string call). */
|
|
54
|
+
export function base64Of(bytes) {
|
|
55
|
+
let out = "";
|
|
56
|
+
for (let i = 0; i < bytes.length; i += 0x8000) out += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
57
|
+
return btoa(out);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What an upload sends, from the files a person picked: { body } or { error }. A folder's files keep their path inside
|
|
61
|
+
* it (its first part, the folder's own name, dropped), so a manifest naming "sprites/idle.png" finds it.
|
|
62
|
+
*/
|
|
63
|
+
export async function packUpload(files) {
|
|
64
|
+
const named = [];
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
const rel = typeof f.webkitRelativePath === "string" && f.webkitRelativePath ? f.webkitRelativePath.split("/").slice(1).join("/") : f.name;
|
|
67
|
+
named.push({ name: rel || f.name, file: f });
|
|
68
|
+
}
|
|
69
|
+
const man = named.find((x) => x.name === "manifest.json") || named.find((x) => x.name.endsWith("/manifest.json") || x.file.name === "manifest.json");
|
|
70
|
+
if (!man) return { error: te("noManifest") };
|
|
71
|
+
let manifest;
|
|
72
|
+
try { manifest = JSON.parse(await man.file.text()); } catch { return { error: te("badManifest") }; }
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const x of named) {
|
|
75
|
+
if (x === man || !/\.png$/i.test(x.name)) continue;
|
|
76
|
+
out[x.name] = base64Of(new Uint8Array(await x.file.arrayBuffer()));
|
|
77
|
+
}
|
|
78
|
+
return { body: { manifest, files: out } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Packs({ packs, store }) {
|
|
82
|
+
const [busy, setBusy] = useState(false);
|
|
83
|
+
const [msg, setMsg] = useState("");
|
|
84
|
+
const send = async (e) => {
|
|
85
|
+
const input = e.target, files = [...(input.files || [])];
|
|
86
|
+
if (!files.length) return;
|
|
87
|
+
setBusy(true); setMsg(te("uploading"));
|
|
88
|
+
try {
|
|
89
|
+
const up = await packUpload(files);
|
|
90
|
+
if (up.error) { setMsg(te("uploadFailed", up.error)); return; }
|
|
91
|
+
const r = await store.uploadSkin(up.body);
|
|
92
|
+
if (r.ok) { setMsg(""); toast(te("uploaded", (r.pack && r.pack.name) || up.body.manifest.id)); }
|
|
93
|
+
else setMsg(te("uploadFailed", r.detail ? `${r.error} — ${r.detail}` : r.error));
|
|
94
|
+
} finally { setBusy(false); try { input.value = ""; } catch { /* a read-only value */ } }
|
|
95
|
+
};
|
|
96
|
+
const remove = async (pk) => {
|
|
97
|
+
setBusy(true); setMsg("");
|
|
98
|
+
try {
|
|
99
|
+
const r = await store.deleteSkin(pk.id);
|
|
100
|
+
if (r.ok) toast(te("removedPack", pk.name)); else setMsg(te("removeFailed", r.error || r.status));
|
|
101
|
+
} finally { setBusy(false); }
|
|
102
|
+
};
|
|
103
|
+
return html`<fieldset class="oe-packs oe-form" disabled=${busy}>
|
|
104
|
+
<legend class="oe-label">${te("packs")}</legend>
|
|
105
|
+
<p class="note">${te("packsLead")}</p>
|
|
106
|
+
${packs.length ? html`<ul class="oe-pack-list">${packs.map((pk) => html`<li key=${pk.id} class="oe-pack" data-pack=${pk.id}>
|
|
107
|
+
<span>${pk.name}</span>${pk.name !== pk.id ? html` <span class="muted mono">${pk.id}</span>` : null}
|
|
108
|
+
<button type="button" class="btn btn-sm btn-danger oe-pack-remove" onClick=${() => remove(pk)} aria-label=${te("removePack", pk.name)}>${te("remove")}</button></li>`)}</ul>`
|
|
109
|
+
: html`<p class="note" id="oePacksNone">${te("packsNone")}</p>`}
|
|
110
|
+
<label class="btn btn-sm oe-upload">${te("upload")}<input type="file" class="sr-only oe-upload-files" multiple accept=".json,.png,application/json,image/png" onChange=${send} /></label>
|
|
111
|
+
<label class="btn btn-sm oe-upload">${te("uploadFolder")}<input type="file" class="sr-only oe-upload-dir" webkitdirectory="" multiple onChange=${send} /></label>
|
|
112
|
+
${msg ? html`<p class="note oe-pack-msg" role="status">${msg}</p>` : null}
|
|
113
|
+
</fieldset>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
43
116
|
/** A new office's id: unique among `taken`, the store's pattern ([A-Za-z0-9_-], never "auto"). */
|
|
44
117
|
export function newOfficeId(taken, rand = Math.random) {
|
|
45
118
|
for (;;) {
|
|
@@ -49,7 +122,7 @@ export function newOfficeId(taken, rand = Math.random) {
|
|
|
49
122
|
}
|
|
50
123
|
/** What is sent: the office as the host stores it (no empty layout key). */
|
|
51
124
|
export function officeForSave(o) {
|
|
52
|
-
return { id: o.id, name: o.name.trim(), skin: o.skin, members: [...o.members], ...(o.layout ? { layout: o.layout } : {}) };
|
|
125
|
+
return { id: o.id, name: o.name.trim(), skin: o.skin, members: [...o.members], ...(o.layout ? { layout: o.layout } : {}), ...(o.overrides ? { overrides: o.overrides } : {}) };
|
|
53
126
|
}
|
|
54
127
|
|
|
55
128
|
function Members({ office, view, everyone, onChange }) {
|
|
@@ -69,7 +142,7 @@ function Members({ office, view, everyone, onChange }) {
|
|
|
69
142
|
* the snapshot in force (the page keeps a newer one it already shows: #2431 review); `current()` that snapshot now (null:
|
|
70
143
|
* not known), read when the person chooses after a 409 (#2449 review); `onClose`.
|
|
71
144
|
*/
|
|
72
|
-
export function OfficeEditor({ offices, rev, view, store, ask = () => 0, current = () => null, onSaved, onClose }) {
|
|
145
|
+
export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () => 0, current = () => null, onSaved, onClose }) {
|
|
73
146
|
// The draft and the rev it was read at go together (#2424 review): a list the stream re-reads meanwhile never moves
|
|
74
147
|
// the rev under an older draft (a save would then overwrite what it never showed). After a 409 the person chooses
|
|
75
148
|
// (#2431): keep their edits (on the newer rev: Save then replaces what was saved meanwhile, knowingly) or load the
|
|
@@ -129,7 +202,8 @@ export function OfficeEditor({ offices, rev, view, store, ask = () => 0, current
|
|
|
129
202
|
<label class="oe-field"><span class="oe-label">${te("styleLabel")}</span>
|
|
130
203
|
<select class="tb-select oe-style" value=${o.skin} onChange=${(e) => set(o.id, { skin: e.target.value })}>
|
|
131
204
|
${STYLES.map((s) => html`<option key=${s} value=${s}>${t(`orgo.style_${s}`)}</option>`)}
|
|
132
|
-
${
|
|
205
|
+
${packs.map((pk) => html`<option key=${`pack:${pk.id}`} value=${pk.id}>${pk.name}</option>`)}
|
|
206
|
+
${STYLES.includes(o.skin) || packs.some((pk) => pk.id === o.skin) ? null : html`<option key=${o.skin} value=${o.skin}>${te("styleUnknown", o.skin)}</option>`}</select></label>
|
|
133
207
|
<button type="button" class="btn btn-sm oe-members-btn" aria-expanded=${open === o.id ? "true" : "false"} onClick=${() => setOpen(open === o.id ? null : o.id)}>
|
|
134
208
|
${te("members")}: ${o.members.length ? te("membersN", o.members.length) : te("everyone")}</button>
|
|
135
209
|
<button type="button" class="btn btn-sm btn-danger oe-remove" onClick=${() => { setDraft((d) => d.filter((x) => x.id !== o.id)); if (open === o.id) setOpen(null); }}>${te("remove")}</button>
|
|
@@ -141,6 +215,7 @@ export function OfficeEditor({ offices, rev, view, store, ask = () => 0, current
|
|
|
141
215
|
${draft.length >= MAX_OFFICES ? html`<span class="note">${te("tooMany", MAX_OFFICES)}</span>` : null}
|
|
142
216
|
</fieldset>
|
|
143
217
|
${msg ? html`<p class="note oe-msg" role="status">${msg}</p>` : null}
|
|
218
|
+
${store.uploadSkin ? html`<${Packs} packs=${packs} store=${store} />` : null}
|
|
144
219
|
${conflict ? html`<div class="oe-conflict">
|
|
145
220
|
<button type="button" class="btn btn-sm" id="oeKeepMine" onClick=${keepMine} disabled=${busy}>${te("keepMine")}</button>
|
|
146
221
|
<button type="button" class="btn btn-sm" id="oeLoadTheirs" onClick=${loadTheirs} disabled=${busy}>${te("loadTheirs")}</button>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Fleet → Org chart → Office → Edit layout (#2513): an admin moves things on a stored office's zones skin — its seats,
|
|
2
|
+
// furniture, the door and the walkable graph's waypoints — and adds or removes waypoints and the corridors between
|
|
3
|
+
// them. The pack stays as it was uploaded: what differs from it is kept as the office's `overrides` (fleet-office-pack.js
|
|
4
|
+
// overridesBetween), saved with the whole list and the rev read (PUT /ui/offices; a change made meanwhile is a 409).
|
|
5
|
+
// Reset goes back to the pack's own positions. A "show coordinates" overlay draws the 1280×720 logical grid.
|
|
6
|
+
//
|
|
7
|
+
// Every handle is a button for the keyboard too: Tab to it, the arrow keys move it (Shift: by 10), Delete removes a
|
|
8
|
+
// waypoint; the selected waypoint's corridors are listed with a way to join or unjoin each. Never offered to a guest,
|
|
9
|
+
// the public link or a read-only session (the host refuses their saves too).
|
|
10
|
+
//
|
|
11
|
+
// The pure part (handles, moving, adding and removing) is exported for tests; LayoutEditor is the overlay.
|
|
12
|
+
import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
|
|
13
|
+
import { t, register } from "/assets/app-i18n.js";
|
|
14
|
+
import { LOGICAL, overridesBetween, own } from "./fleet-office-pack.js";
|
|
15
|
+
|
|
16
|
+
register("orgl", {
|
|
17
|
+
en: {
|
|
18
|
+
edit: "Edit layout", title: "Edit layout", coords: "Show coordinates", addWaypoint: "Add waypoint", removeWaypoint: "Remove waypoint",
|
|
19
|
+
joinTo: "Join to", join: "Join", unjoin: "Unjoin {0}", corridors: "Corridors", none: "Nothing selected: pick a seat, an item, the door or a waypoint.",
|
|
20
|
+
reset: "Reset to the pack's layout", save: "Save layout", cancel: "Cancel", saved: "Layout saved.", hint: "Drag, or use the arrow keys (Shift: by 10).",
|
|
21
|
+
seat: "Seat {0} in {1}", furniture: "Item {0}", door: "Door", waypoint: "Waypoint {0}", at: "{0} at {1}, {2}",
|
|
22
|
+
changed: "Someone changed the offices meanwhile. Your layout is kept: Save again puts it on the current list.",
|
|
23
|
+
failed: "The layout was not saved ({0}).", rateLimited: "Too many saves. Try again in a minute.",
|
|
24
|
+
stale: "This office was removed or its style changed meanwhile: this layout can no longer be saved. Cancel closes it.",
|
|
25
|
+
},
|
|
26
|
+
"zh-TW": {
|
|
27
|
+
edit: "編輯配置", title: "編輯配置", coords: "顯示座標", addWaypoint: "新增路徑點", removeWaypoint: "移除路徑點",
|
|
28
|
+
joinTo: "連到", join: "連接", unjoin: "取消連接 {0}", corridors: "走道", none: "尚未選取:請選座位、家具、門或路徑點。",
|
|
29
|
+
reset: "還原成 pack 的配置", save: "儲存配置", cancel: "取消", saved: "配置已儲存。", hint: "拖曳,或用方向鍵移動(Shift:一次 10)。",
|
|
30
|
+
seat: "{1} 的座位 {0}", furniture: "家具 {0}", door: "門", waypoint: "路徑點 {0}", at: "{0},位置 {1}, {2}",
|
|
31
|
+
changed: "有人在這期間修改了辦公室。你的配置已保留:再按一次儲存,會套用到目前的清單。",
|
|
32
|
+
failed: "配置沒有儲存({0})。", rateLimited: "儲存太頻繁,請一分鐘後再試。",
|
|
33
|
+
stale: "這個辦公室在這期間被移除,或它的風格已改變:這份配置無法再儲存。按取消關閉。",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const tl = (k, ...v) => t(`orgl.${k}`, ...v);
|
|
37
|
+
|
|
38
|
+
// ── The pure part ──
|
|
39
|
+
|
|
40
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, Math.round(v)));
|
|
41
|
+
/** Every handle on a pack: { key, kind, label, x, y } — seats by area, furniture, the door, the waypoints. */
|
|
42
|
+
export function handlesOf(pack) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const [name, a] of Object.entries(pack.areas)) a.seats.forEach((s, i) => out.push({ key: `seat:${name}:${i}`, kind: "seat", label: tl("seat", i + 1, name), x: s.x, y: s.y }));
|
|
45
|
+
for (const f of pack.furniture) out.push({ key: `furn:${f.id}`, kind: "furniture", label: tl("furniture", f.id), x: f.x, y: f.y });
|
|
46
|
+
out.push({ key: "door", kind: "door", label: tl("door"), x: pack.door.x, y: pack.door.y });
|
|
47
|
+
for (const w of pack.waypoints) out.push({ key: `wp:${w.id}`, kind: "waypoint", label: tl("waypoint", w.id), x: w.x, y: w.y });
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** The pack with handle `key` at (x, y), rounded and kept in range: a seat inside its area, anything else on the canvas. */
|
|
51
|
+
export function moveHandle(pack, key, x, y) {
|
|
52
|
+
const [kind, a, b] = key.split(":");
|
|
53
|
+
const inCanvas = () => ({ x: clamp(x, 0, LOGICAL.w), y: clamp(y, 0, LOGICAL.h) });
|
|
54
|
+
if (kind === "seat") {
|
|
55
|
+
const area = own(pack.areas, a), i = Number(b);
|
|
56
|
+
if (!area || !area.seats[i]) return pack;
|
|
57
|
+
const r = area.rect, p = { x: clamp(x, Math.ceil(r.x), Math.floor(r.x + r.w)), y: clamp(y, Math.ceil(r.y), Math.floor(r.y + r.h)) };
|
|
58
|
+
return { ...pack, areas: { ...pack.areas, [a]: { ...area, seats: area.seats.map((s, j) => (j === i ? p : s)) } } };
|
|
59
|
+
}
|
|
60
|
+
if (kind === "furn") return { ...pack, furniture: pack.furniture.map((f) => (f.id === a ? { ...f, ...inCanvas() } : f)) };
|
|
61
|
+
if (kind === "door") return { ...pack, door: inCanvas() };
|
|
62
|
+
if (kind === "wp") return { ...pack, waypoints: pack.waypoints.map((w) => (w.id === a ? { ...w, ...inCanvas() } : w)) };
|
|
63
|
+
return pack;
|
|
64
|
+
}
|
|
65
|
+
/** A new waypoint at (x, y), named wp-<n> (a name no other waypoint has); returns { pack, id }. */
|
|
66
|
+
export function addWaypoint(pack, x = LOGICAL.w / 2, y = LOGICAL.h / 2) {
|
|
67
|
+
const ids = new Set(pack.waypoints.map((w) => w.id));
|
|
68
|
+
let n = pack.waypoints.length + 1;
|
|
69
|
+
while (ids.has(`wp-${n}`)) n++;
|
|
70
|
+
const id = `wp-${n}`;
|
|
71
|
+
return { pack: { ...pack, waypoints: [...pack.waypoints, { id, x: clamp(x, 0, LOGICAL.w), y: clamp(y, 0, LOGICAL.h) }] }, id };
|
|
72
|
+
}
|
|
73
|
+
/** The pack without waypoint `id` and its corridors. */
|
|
74
|
+
export function removeWaypoint(pack, id) {
|
|
75
|
+
return { ...pack, waypoints: pack.waypoints.filter((w) => w.id !== id), edges: pack.edges.filter((e) => e[0] !== id && e[1] !== id) };
|
|
76
|
+
}
|
|
77
|
+
/** A corridor between `a` and `b` added, or removed when there is one (either way round). */
|
|
78
|
+
export function toggleEdge(pack, a, b) {
|
|
79
|
+
if (a === b || !pack.waypoints.some((w) => w.id === a) || !pack.waypoints.some((w) => w.id === b)) return pack;
|
|
80
|
+
const has = (e) => (e[0] === a && e[1] === b) || (e[0] === b && e[1] === a);
|
|
81
|
+
return { ...pack, edges: pack.edges.some(has) ? pack.edges.filter((e) => !has(e)) : [...pack.edges, [a, b]] };
|
|
82
|
+
}
|
|
83
|
+
/** What Save keeps: the overrides between the pack and the edited layout (null: the pack's own). */
|
|
84
|
+
export const layoutOverrides = (base, edited) => overridesBetween(base, edited);
|
|
85
|
+
|
|
86
|
+
// ── The overlay ──
|
|
87
|
+
|
|
88
|
+
const GRID = 80, LABEL = 160;
|
|
89
|
+
const KEY_STEP = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The editor over the canvas: `base` the pack as uploaded; `start` the layout shown when it opens (the base with the
|
|
93
|
+
* office's overrides on it); `place(el)` puts the overlay over the scene wherever the view has it (#2540: panned and
|
|
94
|
+
* zoomed; the page sets its box through the CSSOM, never a style attribute: #1300) — a pointer is read back through
|
|
95
|
+
* the overlay's own box, so a drag lands on the logical point under it at any zoom; `onChange(edited)` as anything
|
|
96
|
+
* moves (the canvas redraws it); `onSave(overrides | null)` → { ok } or { ok: false, message }; `onClose()`. `stale` (a message): the
|
|
97
|
+
* office or its style changed underneath the session — no handles, nothing to save, only Cancel.
|
|
98
|
+
*/
|
|
99
|
+
export function LayoutEditor({ base, start, place = null, stale = "", onChange, onSave, onClose }) {
|
|
100
|
+
const [edited, setEdited] = useState(start);
|
|
101
|
+
const [sel, setSel] = useState(null);
|
|
102
|
+
const [coords, setCoords] = useState(true);
|
|
103
|
+
const [busy, setBusy] = useState(false);
|
|
104
|
+
const [msg, setMsg] = useState("");
|
|
105
|
+
const [joinTo, setJoinTo] = useState("");
|
|
106
|
+
const svg = useRef(null), drag = useRef(null), overlay = useRef(null);
|
|
107
|
+
useLayoutEffect(() => { if (place) place(overlay.current); });
|
|
108
|
+
const change = (next) => { setEdited(next); onChange(next); };
|
|
109
|
+
useEffect(() => { onChange(start); }, []);
|
|
110
|
+
const handles = handlesOf(edited);
|
|
111
|
+
const selected = sel ? handles.find((h) => h.key === sel) || null : null;
|
|
112
|
+
const toLogical = (e) => {
|
|
113
|
+
const r = svg.current && svg.current.getBoundingClientRect ? svg.current.getBoundingClientRect() : null;
|
|
114
|
+
if (!r || !r.width || !r.height) return null;
|
|
115
|
+
return { x: ((e.clientX - r.left) / r.width) * LOGICAL.w, y: ((e.clientY - r.top) / r.height) * LOGICAL.h };
|
|
116
|
+
};
|
|
117
|
+
const down = (key) => (e) => {
|
|
118
|
+
if (busy) return;
|
|
119
|
+
setSel(key); drag.current = key;
|
|
120
|
+
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch { /* not captured: moves still reach the svg */ }
|
|
121
|
+
};
|
|
122
|
+
const move = (e) => { if (!drag.current) return; const p = toLogical(e); if (p) change(moveHandle(edited, drag.current, p.x, p.y)); };
|
|
123
|
+
const up = () => { drag.current = null; };
|
|
124
|
+
const keyOn = (h) => (e) => {
|
|
125
|
+
if (busy) return;
|
|
126
|
+
const d = KEY_STEP[e.key];
|
|
127
|
+
if (d) { e.preventDefault(); const k = e.shiftKey ? 10 : 1; setSel(h.key); change(moveHandle(edited, h.key, h.x + d[0] * k, h.y + d[1] * k)); return; }
|
|
128
|
+
if ((e.key === "Delete" || e.key === "Backspace") && h.kind === "waypoint") { e.preventDefault(); change(removeWaypoint(edited, h.key.slice(3))); setSel(null); }
|
|
129
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSel(h.key); }
|
|
130
|
+
};
|
|
131
|
+
const add = () => { const at = selected ? { x: selected.x + 40, y: selected.y } : { x: LOGICAL.w / 2, y: LOGICAL.h / 2 }; const r = addWaypoint(edited, at.x, at.y); change(r.pack); setSel(`wp:${r.id}`); };
|
|
132
|
+
const wpSel = selected && selected.kind === "waypoint" ? selected.key.slice(3) : null;
|
|
133
|
+
const save = async () => {
|
|
134
|
+
if (busy) return;
|
|
135
|
+
setBusy(true); setMsg("");
|
|
136
|
+
try {
|
|
137
|
+
const r = await onSave(layoutOverrides(base, edited));
|
|
138
|
+
if (r && r.ok) { onClose(); return; }
|
|
139
|
+
setMsg(r && r.message ? r.message : tl("failed", "?"));
|
|
140
|
+
} finally { setBusy(false); }
|
|
141
|
+
};
|
|
142
|
+
const reset = () => { change(base); setSel(null); setMsg(""); };
|
|
143
|
+
const grid = [];
|
|
144
|
+
if (coords) {
|
|
145
|
+
for (let x = GRID; x < LOGICAL.w; x += GRID) grid.push(html`<line key=${`gx${x}`} class="ol-grid" x1=${x} y1="0" x2=${x} y2=${LOGICAL.h} />`);
|
|
146
|
+
for (let y = GRID; y < LOGICAL.h; y += GRID) grid.push(html`<line key=${`gy${y}`} class="ol-grid" x1="0" y1=${y} x2=${LOGICAL.w} y2=${y} />`);
|
|
147
|
+
for (let x = 0; x < LOGICAL.w; x += LABEL) grid.push(html`<text key=${`lx${x}`} class="ol-label" x=${x + 3} y="14">${x}</text>`);
|
|
148
|
+
for (let y = LABEL; y < LOGICAL.h; y += LABEL) grid.push(html`<text key=${`ly${y}`} class="ol-label" x="3" y=${y - 3}>${y}</text>`);
|
|
149
|
+
}
|
|
150
|
+
const at = new Map(edited.waypoints.map((w) => [w.id, w]));
|
|
151
|
+
const edgeKey = (e) => `${e[0]}\u0000${e[1]}`;
|
|
152
|
+
if (stale) {
|
|
153
|
+
return html`<div class="ol" role="group" aria-label=${tl("title")}><div class="ol-bar">
|
|
154
|
+
<p class="note ol-msg ol-stale" role="status">${stale}</p>
|
|
155
|
+
<span class="ol-actions"><button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button></span>
|
|
156
|
+
</div></div>`;
|
|
157
|
+
}
|
|
158
|
+
return html`<div class="ol" role="group" aria-label=${tl("title")}>
|
|
159
|
+
<div class="ol-overlay" ref=${overlay}>
|
|
160
|
+
<svg ref=${svg} class="ol-svg" viewBox=${`0 0 ${LOGICAL.w} ${LOGICAL.h}`} width="100%" height="100%"
|
|
161
|
+
onPointerMove=${move} onPointerUp=${up} onPointerCancel=${up} onPointerLeave=${up}>
|
|
162
|
+
${grid}
|
|
163
|
+
${Object.values(edited.areas).map((a) => html`<rect key=${`a:${a.name}`} class="ol-area" x=${a.rect.x} y=${a.rect.y} width=${a.rect.w} height=${a.rect.h} />`)}
|
|
164
|
+
${edited.edges.map((e) => { const p = at.get(e[0]), q = at.get(e[1]); return p && q ? html`<line key=${`e:${edgeKey(e)}`} class="ol-edge" x1=${p.x} y1=${p.y} x2=${q.x} y2=${q.y} />` : null; })}
|
|
165
|
+
${handles.map((h) => html`<g key=${h.key} class=${`ol-handle ol-${h.kind}${sel === h.key ? " on" : ""}`} data-handle=${h.key} tabindex="0" role="button"
|
|
166
|
+
aria-label=${tl("at", h.label, h.x, h.y)} aria-pressed=${sel === h.key ? "true" : "false"}
|
|
167
|
+
onPointerDown=${down(h.key)} onKeyDown=${keyOn(h)} onFocus=${() => setSel(h.key)}>
|
|
168
|
+
${h.kind === "door" ? html`<rect x=${h.x - 9} y=${h.y - 9} width="18" height="18" transform=${`rotate(45 ${h.x} ${h.y})`} />`
|
|
169
|
+
: h.kind === "furniture" ? html`<rect x=${h.x - 8} y=${h.y - 8} width="16" height="16" />`
|
|
170
|
+
: html`<circle cx=${h.x} cy=${h.y} r=${h.kind === "seat" ? 9 : 7} />`}
|
|
171
|
+
${coords && sel === h.key ? html`<text class="ol-coord" x=${h.x + 12} y=${h.y - 10}>${h.x}, ${h.y}</text>` : null}
|
|
172
|
+
</g>`)}
|
|
173
|
+
</svg>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="ol-bar">
|
|
176
|
+
<label class="check"><input type="checkbox" class="ol-coords" checked=${coords} onChange=${() => setCoords((v) => !v)} /><span>${tl("coords")}</span></label>
|
|
177
|
+
<button type="button" class="btn btn-sm ol-add" onClick=${add} disabled=${busy}>${tl("addWaypoint")}</button>
|
|
178
|
+
<span class="muted ol-sel" aria-live="polite">${selected ? tl("at", selected.label, selected.x, selected.y) : tl("none")}</span>
|
|
179
|
+
${wpSel ? html`<button type="button" class="btn btn-sm btn-danger ol-remove" onClick=${() => { change(removeWaypoint(edited, wpSel)); setSel(null); }} disabled=${busy}>${tl("removeWaypoint")}</button>
|
|
180
|
+
<label class="oo-pick"><span class="oo-pick-label">${tl("joinTo")}</span>
|
|
181
|
+
<select class="tb-select ol-join-to" value=${joinTo} onChange=${(e) => setJoinTo(e.target.value)}>
|
|
182
|
+
<option value="">—</option>
|
|
183
|
+
${edited.waypoints.filter((w) => w.id !== wpSel).map((w) => html`<option key=${w.id} value=${w.id}>${w.id}</option>`)}
|
|
184
|
+
</select></label>
|
|
185
|
+
<button type="button" class="btn btn-sm ol-join" disabled=${busy || !joinTo || joinTo === wpSel} onClick=${() => { change(toggleEdge(edited, wpSel, joinTo)); setJoinTo(""); }}>${tl("join")}</button>
|
|
186
|
+
<span class="ol-corridors">${edited.edges.filter((e) => e[0] === wpSel || e[1] === wpSel).map((e) => { const other = e[0] === wpSel ? e[1] : e[0];
|
|
187
|
+
return html`<button key=${edgeKey(e)} type="button" class="btn btn-sm ol-unjoin" data-other=${other} disabled=${busy} onClick=${() => change(toggleEdge(edited, wpSel, other))}>${tl("unjoin", other)}</button>`; })}</span>` : null}
|
|
188
|
+
<span class="ol-actions">
|
|
189
|
+
<button type="button" class="btn btn-sm ol-reset" onClick=${reset} disabled=${busy}>${tl("reset")}</button>
|
|
190
|
+
<button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button>
|
|
191
|
+
<button type="button" class="btn btn-sm btn-primary ol-save" onClick=${save} disabled=${busy}>${tl("save")}</button>
|
|
192
|
+
</span>
|
|
193
|
+
<span class="muted oo-note">${tl("hint")}</span>
|
|
194
|
+
${msg ? html`<p class="note ol-msg" role="status">${msg}</p>` : null}
|
|
195
|
+
</div>
|
|
196
|
+
</div>`;
|
|
197
|
+
}
|