@songsid/agend 2.3.1 → 2.3.2-beta.1
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 +106 -55
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +107 -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-pack.d.ts +39 -0
- package/dist/ui/fleet-office-pack.js +240 -29
- package/dist/ui/fleet-office-skin.js +130 -21
- package/dist/ui/fleet-office-store.js +75 -4
- package/dist/ui/fleet-office-view.js +154 -0
- package/dist/ui/fleet-office-zones.js +95 -33
- package/dist/ui/fleet-office.js +530 -94
- 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 +242 -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/fleet-office.js
CHANGED
|
@@ -5,8 +5,14 @@
|
|
|
5
5
|
// - Two layouts, by skin. Desks (the built-in "pixel" skin, the default): each team is a room, each member a desk with a
|
|
6
6
|
// name tag; General and the instances in no team share the lobby; a grid that wraps at the stage's width
|
|
7
7
|
// (fleet-office-model.js autoLayout). Zones (a skin whose pack maps states to areas — the built-in "studio"): a
|
|
8
|
-
// 1280×720 scene
|
|
9
|
-
//
|
|
8
|
+
// 1280×720 scene; each instance takes a seat in the area for its state and walks there along the pack's waypoints
|
|
9
|
+
// when its state changes (fleet-office-zones.js).
|
|
10
|
+
// - #2540: the canvas fills the Fleet panel (no frame: the whole width of the Fleet area, down to the window's foot);
|
|
11
|
+
// the scene is panned and zoomed as the org chart's canvas is (fleet-canvas-view.js: drag, wheel, pinch, + / − / Fit
|
|
12
|
+
// and the `+ - 0` keys, an overview), Fit filling the panel (contain; Fill: cover), + and − in pixel levels, the zoom
|
|
13
|
+
// shown as a percentage, around it a matte (fleet-office-view.js). The view (zoom and centre) and the fit mode are
|
|
14
|
+
// kept per device per office and style. Everything on the scene — hit-testing, tooltips, Edit layout's handles —
|
|
15
|
+
// goes through the same view transform.
|
|
10
16
|
// - Speech bubbles: now and then a short line for its state (typed out; at once with reduced motion) — the page's own
|
|
11
17
|
// lines or the skin's, never anything an agent said. Furniture can react: an item with `activeWhen` animates while
|
|
12
18
|
// anyone shown is in that state.
|
|
@@ -20,24 +26,46 @@
|
|
|
20
26
|
// - Who is shown: an office switcher (the whole fleet, or an office from the host), and for the whole fleet a team (or
|
|
21
27
|
// the lobby) and pinned people — kept per browser (fleet-office-store.js).
|
|
22
28
|
// - One requestAnimationFrame loop, drawing only when a frame changes; it stops while the tab is hidden and when the view
|
|
23
|
-
// goes. Reduced motion: still poses, nothing in the air. Colours are the dashboard's
|
|
24
|
-
//
|
|
29
|
+
// goes. Reduced motion: still poses, nothing in the air (zoom and pan are never animated). Colours are the dashboard's
|
|
30
|
+
// tokens (dark and light).
|
|
31
|
+
// - Drawing is the skin's (fleet-office-skin.js); a skin pack an admin uploaded (fleet-office-pack.js, #2513) replaces
|
|
32
|
+
// any part — an office names it as its style; one this page cannot load is drawn as Desks, and says so.
|
|
33
|
+
// - Crowds (#2513): an area with more people than seats packs the rest into a tighter grid, then counts who is left
|
|
34
|
+
// ("+N", in its corner, never over its name); there idle and asleep people say nothing, at most a few speak (calls
|
|
35
|
+
// first, then work), no bubble covers a name tag, and the tags show on hover or focus only. "Hide paused and stopped"
|
|
36
|
+
// (per browser) leaves the sleepers out.
|
|
37
|
+
// - Edit layout (an admin, a stored office on a zones skin): fleet-office-layout.js, saved as the office's overrides.
|
|
38
|
+
// An editing session (#2513 review) is bound to the office it opened on, that office's style (the pack's id and sha)
|
|
39
|
+
// and the list and rev read then: a re-read of the offices meanwhile (the stream) changes the view, never the session,
|
|
40
|
+
// so Save sends that rev and a change made meanwhile is a 409 (the session then takes the list read again, and Save
|
|
41
|
+
// again is the person's decision). While it is open the office picker and Edit offices wait; if the office goes or its
|
|
42
|
+
// style changes underneath it, the editor says so and only Cancel is left.
|
|
43
|
+
// - Fullscreen (#2540): a button by the zoom controls, or `f`; Esc leaves. The Fullscreen API on the view's container
|
|
44
|
+
// (webkit's prefix too); where a page cannot take an element fullscreen (an iPhone) or the browser refuses, "immersive":
|
|
45
|
+
// the container covers the window (CSS). Either way only the stage shows, with its floating controls (zoom, Fit, the
|
|
46
|
+
// overview, a compact office switcher, Exit); the stage's resize handling re-fits it. Nothing about it is kept.
|
|
25
47
|
import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
|
|
26
48
|
import { appStore } from "/assets/app-store.js";
|
|
27
49
|
import { t, lang, register } from "/assets/app-i18n.js";
|
|
28
50
|
import { identityOf } from "/assets/identity-color.js";
|
|
51
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
29
52
|
import { nodeText } from "./fleet-org.js";
|
|
30
53
|
import { hits, needle } from "./fleet-canvas-layout.js";
|
|
31
54
|
import { AUTO_ID, LOBBY, autoLayout, backingScale, coatOf, createFlights, customLayout, deskAt, desksOf, filterOk, lampOf, layoutOk, lightOf, membersOf,
|
|
32
55
|
officePose, officeRooms, signOf, workOn } from "./fleet-office-model.js";
|
|
33
|
-
import { CHAR_DEPTH, DEFAULT_PAL, bubbleBox, modeOf, skinFor } from "./fleet-office-skin.js";
|
|
34
|
-
import { CALLING, activeFurniture, bubbleAt, createWalkers, linesFor, personAt, placeBubbles, zonesLayout } from "./fleet-office-zones.js";
|
|
56
|
+
import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadPackSkin, modeOf, skinFor, tagBox } from "./fleet-office-skin.js";
|
|
57
|
+
import { CALLING, activeFurniture, bubbleAt, bubbleRank, createWalkers, linesFor, personAt, placeBubbles, thinBubbles, zonesLayout } from "./fleet-office-zones.js";
|
|
35
58
|
import { createOfficeStore } from "./fleet-office-store.js";
|
|
36
|
-
import { OfficeEditor } from "./fleet-office-editor.js";
|
|
59
|
+
import { OfficeEditor, officeForSave } from "./fleet-office-editor.js";
|
|
60
|
+
import { LayoutEditor, handlesOf } from "./fleet-office-layout.js";
|
|
61
|
+
import { applyOverrides } from "./fleet-office-pack.js";
|
|
62
|
+
import { centreOn, gestureState, gestures, miniPoint, toScreen, toWorld, wheelFactor, zoomKey, zoomTo } from "./fleet-canvas-view.js";
|
|
63
|
+
import { OK_MAX, OK_MIN, clampPan, desksK, edgeMatte, edgeStrips, fitScene, layerScale, miniBox, miniFrame, openingView, pinchOffice, savedOf, stepK, viewFromSaved,
|
|
64
|
+
zoomLabel, zoomOffice } from "./fleet-office-view.js";
|
|
37
65
|
|
|
38
66
|
register("orgo", {
|
|
39
67
|
en: {
|
|
40
|
-
label: "Office: each team is a room and each instance a desk; General and the instances in no team are in the lobby.",
|
|
68
|
+
label: "Office: each team is a room and each instance a desk; General and the instances in no team are in the lobby. Drag to pan; the wheel, a pinch, + and − zoom, 0 fits, F is fullscreen.",
|
|
41
69
|
office: "Office", allOffice: "Whole fleet", show: "Show", allTeams: "All teams", lobby: "Lobby (General, no team)", lobbyRoom: "Lobby",
|
|
42
70
|
people: "People", peopleN: "People ({0})", findPerson: "Find a person", everyone: "Show everyone", nobody: "No one here matches this filter.",
|
|
43
71
|
unseated: "{0} without a seat", skinUnknown: "Its style “{0}” is not available here: drawn as Desks.", closed: "Closed", desks: "Desks", tapAgain: "Tap again to open the chat",
|
|
@@ -46,9 +74,12 @@ register("orgo", {
|
|
|
46
74
|
b_working: "On it.|Typing…|Almost there|In the zone|One more test", b_idle: "Tea time|Ready when you are|Stretching|Anything for me?",
|
|
47
75
|
b_paused: "Zzz…|Back later", b_stopped: "Off duty|Zzz…", b_needs: "Need you!|A question for you|Over here!",
|
|
48
76
|
b_crashed: "Ouch, crashed|Help!", b_stuck: "Hmm, stuck?|Still thinking…",
|
|
77
|
+
hideAsleep: "Hide paused and stopped", layoutIgnored: "Its saved layout does not fit this style: drawn as the style's own.",
|
|
78
|
+
fit: "Fit the office", overview: "Overview: click to move there", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen",
|
|
79
|
+
fill: "Fill", fillTitle: "Fill the panel (the scene's overflow is cropped; drag to see it)", zoomLevel: "Zoom {0}",
|
|
49
80
|
},
|
|
50
81
|
"zh-TW": {
|
|
51
|
-
label: "辦公室:每個 team 是一間房間,每個 instance 一張桌子;General 和未加入 team 的 instance
|
|
82
|
+
label: "辦公室:每個 team 是一間房間,每個 instance 一張桌子;General 和未加入 team 的 instance 在大廳。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示整個場景,F 切換全螢幕。",
|
|
52
83
|
office: "辦公室", allOffice: "整個 fleet", show: "顯示", allTeams: "所有 team", lobby: "大廳(General、未加入 team)", lobbyRoom: "大廳",
|
|
53
84
|
people: "成員", peopleN: "成員({0})", findPerson: "尋找成員", everyone: "顯示全部", nobody: "沒有符合這個篩選的成員。",
|
|
54
85
|
unseated: "{0} 位沒有座位", skinUnknown: "這裡沒有它的風格「{0}」:以辦公桌顯示。", closed: "休息中", desks: "桌子", tapAgain: "再點一下開啟聊天",
|
|
@@ -57,6 +88,9 @@ register("orgo", {
|
|
|
57
88
|
b_working: "處理中|打字中…|快好了|專心中|再測一次", b_idle: "喝茶中|隨時可以開工|伸個懶腰|有事找我嗎?",
|
|
58
89
|
b_paused: "Zzz…|晚點回來", b_stopped: "下班了|Zzz…", b_needs: "需要你!|有個問題|在這裡!",
|
|
59
90
|
b_crashed: "糟糕,當掉了|救命!", b_stuck: "嗯,卡住了?|還在想…",
|
|
91
|
+
hideAsleep: "隱藏暫停與停止的成員", layoutIgnored: "它儲存的配置不適用這個風格:以風格本身的配置顯示。",
|
|
92
|
+
fit: "顯示整個辦公室", overview: "總覽:點一下移到那裡", fullscreen: "全螢幕", exitFullscreen: "結束全螢幕",
|
|
93
|
+
fill: "填滿", fillTitle: "填滿整個面板(超出的部分會被裁切,拖曳即可看到)", zoomLevel: "縮放 {0}",
|
|
60
94
|
},
|
|
61
95
|
});
|
|
62
96
|
const to = (k, ...v) => t(`orgo.${k}`, ...v);
|
|
@@ -67,10 +101,36 @@ const CAPTION_OF_STATE = { starting: "capStarting", queued: "capQueued" };
|
|
|
67
101
|
export const caption = (pose, state) => to(CAPTION_OF_STATE[state] || CAPTION[pose] || "capIdle");
|
|
68
102
|
|
|
69
103
|
const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
104
|
+
/** The device's ratio (CSS px → device px), kept sane. */
|
|
105
|
+
const deviceRatio = () => Math.max(0.5, Math.min(4, Number(globalThis.devicePixelRatio) || 1));
|
|
106
|
+
/** The stage's size before it is measured (a test's page has none). */
|
|
107
|
+
const SIZE0 = { w: 960, h: 600 };
|
|
108
|
+
const PAN_KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
109
|
+
/** Typing: no single-key shortcut (the `f` of fullscreen) there. */
|
|
110
|
+
const typing = (el) => !!el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA" || el.isContentEditable === true);
|
|
111
|
+
/** The element the page has fullscreen (standard, else webkit's), or null. */
|
|
112
|
+
const fullscreenEl = () => { const d = globalThis.document; return (d && (d.fullscreenElement || d.webkitFullscreenElement)) || null; };
|
|
113
|
+
/** A pack's background, sampled once for its matte (fleet-office-view.js edgeMatte): picture → #rrggbb or null. */
|
|
114
|
+
const MATTES = new WeakMap();
|
|
115
|
+
function sampledMatte(img) {
|
|
116
|
+
if (!img || typeof img !== "object") return null;
|
|
117
|
+
if (MATTES.has(img)) return MATTES.get(img);
|
|
118
|
+
let m = null;
|
|
119
|
+
try {
|
|
120
|
+
const c = document.createElement("canvas");
|
|
121
|
+
c.width = 64; c.height = 36;
|
|
122
|
+
const g = c.getContext && c.getContext("2d");
|
|
123
|
+
if (g) { g.imageSmoothingEnabled = false; g.drawImage(img, 0, 0, 64, 36); const st = edgeStrips(c); m = st ? edgeMatte(st) : null; }
|
|
124
|
+
} catch { m = null; }
|
|
125
|
+
MATTES.set(img, m);
|
|
126
|
+
return m;
|
|
127
|
+
}
|
|
70
128
|
const now = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
|
|
71
129
|
/** How often the picture can change when nothing is in the air (the poses loop at 4 fps; a bubble types faster). */
|
|
72
130
|
const FPS = 12;
|
|
73
|
-
const STYLES =
|
|
131
|
+
const STYLES = BUILT_IN_SKINS;
|
|
132
|
+
/** Paused and stopped: left out when "Hide paused and stopped" is on. */
|
|
133
|
+
const ASLEEP = new Set(["paused", "stopped"]);
|
|
74
134
|
const BUBBLE_STATES = ["working", "idle", "paused", "stopped", "needs", "crashed", "stuck"];
|
|
75
135
|
/** The page's own bubble lines, by state, in its language (short, never message content). */
|
|
76
136
|
export const bubbleDefaults = () => Object.fromEntries(BUBBLE_STATES.map((s) => [s, to(`b_${s}`).split("|")]));
|
|
@@ -135,12 +195,24 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
135
195
|
const [choice, setChoice] = useState(() => store.choice());
|
|
136
196
|
const [filter, setFilter] = useState(() => store.filter());
|
|
137
197
|
const [style, setStyle] = useState(() => store.skin());
|
|
138
|
-
const [
|
|
139
|
-
const [
|
|
198
|
+
const [hideAsleep, setHideAsleep] = useState(() => store.hideAsleep());
|
|
199
|
+
const [packs, setPacks] = useState([]); // the skin packs the host keeps: [{ id, name, sha }]
|
|
200
|
+
const [packSkins, setPackSkins] = useState(() => new Map()); // id@sha → a loaded skin, or null (not a pack)
|
|
201
|
+
// Edit layout's session while it is open: { id, officeId, skin, pack (the style's identity), base (the pack as kept),
|
|
202
|
+
// start, draft, rev, list (the offices and rev read when it opened — what Save sends) }; null when closed.
|
|
203
|
+
const [layout, setLayout] = useState(null);
|
|
204
|
+
const layoutRef = useRef(null), layoutSeq = useRef(0);
|
|
205
|
+
layoutRef.current = layout;
|
|
206
|
+
const [width, setWidth] = useState(SIZE0.w); // the stage's size (CSS px; the canvas fills it)
|
|
207
|
+
const [stageH, setStageH] = useState(0); // (0: not known yet)
|
|
208
|
+
const [dpr, setDpr] = useState(deviceRatio);
|
|
140
209
|
const [themeRev, setThemeRev] = useState(0);
|
|
141
210
|
const [pal, setPal] = useState(DEFAULT_PAL); // the tokens in force (read once the stage is there)
|
|
142
211
|
const [tip, setTip] = useState(null); // the desk key the tooltip is for
|
|
143
212
|
const stageRef = useRef(null), canvasRef = useRef(null), tipRef = useRef(null), lastType = useRef("mouse");
|
|
213
|
+
// Fullscreen: null, "native" (the Fullscreen API) or "immersive" (the CSS cover); per visit, never kept.
|
|
214
|
+
const ooRef = useRef(null), [fs, setFs] = useState(null), fsRef = useRef(null), fsAsked = useRef(false), fsTarget = useRef(null);
|
|
215
|
+
fsRef.current = fs;
|
|
144
216
|
const flights = useMemo(() => createFlights(), []);
|
|
145
217
|
const walkers = useMemo(() => createWalkers(), []);
|
|
146
218
|
const seatsRef = useRef(new Map()); // the zones layout's last seats: kept where they can be
|
|
@@ -169,6 +241,15 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
169
241
|
const off = sub ? lease.hold(sub("offices", () => { void read(); })) : null;
|
|
170
242
|
return () => { if (off) off(); };
|
|
171
243
|
}, [lease, store]);
|
|
244
|
+
// The skin packs: on opening, and again whenever the stream says one was uploaded or deleted.
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!lease) return undefined;
|
|
247
|
+
const read = async () => { const d = await store.skins(); if (lease.current() && d) setPacks(d.packs); };
|
|
248
|
+
void read();
|
|
249
|
+
const sub = appStore.get().streamOn;
|
|
250
|
+
const off = sub ? lease.hold(sub("office_skins", () => { void read(); })) : null;
|
|
251
|
+
return () => { if (off) off(); };
|
|
252
|
+
}, [lease, store]);
|
|
172
253
|
// Whether this page may edit the offices: an admin session (the host asks again on every save), never a guest page or
|
|
173
254
|
// the public link (whose sessions the host refuses outright).
|
|
174
255
|
useEffect(() => {
|
|
@@ -186,6 +267,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
186
267
|
const w = el && el.clientWidth, h = el && el.clientHeight;
|
|
187
268
|
if (w > 0) setWidth((cur) => (Math.abs(cur - w) >= 1 ? Math.floor(w) : cur));
|
|
188
269
|
if (h > 0) setStageH((cur) => (Math.abs(cur - h) >= 1 ? Math.floor(h) : cur));
|
|
270
|
+
setDpr(deviceRatio()); // the browser's zoom changes it
|
|
189
271
|
};
|
|
190
272
|
measure();
|
|
191
273
|
const ro = typeof ResizeObserver === "function" ? new ResizeObserver(measure) : null;
|
|
@@ -204,19 +286,51 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
204
286
|
setPal((cur) => (JSON.stringify(cur) === JSON.stringify(next) ? cur : next));
|
|
205
287
|
}, [themeRev]);
|
|
206
288
|
const mode = modeOf(pal);
|
|
289
|
+
const size = { w: width, h: stageH > 0 ? stageH : SIZE0.h };
|
|
290
|
+
// The canvas bitmap's px per CSS px: the device's, unless the bitmap would be too big to paint (backingScale). Zoom
|
|
291
|
+
// snaps to whole numbers of these (fleet-office-view.js), so a pixel-level view maps a logical unit onto whole pixels.
|
|
292
|
+
const ratio = backingScale(size.w, size.h, dpr);
|
|
207
293
|
|
|
208
294
|
const custom = choice !== AUTO_ID ? stored.find((o) => o.id === choice) || null : null;
|
|
209
|
-
|
|
210
|
-
const
|
|
295
|
+
// The style: a built-in one, or an uploaded pack once it is loaded (until then, and when it is not one, Desks).
|
|
296
|
+
const wanted = custom ? custom.skin : style;
|
|
297
|
+
const packInfo = STYLES.includes(wanted) ? null : packs.find((pk) => pk.id === wanted) || null;
|
|
298
|
+
const packKey = packInfo ? `${packInfo.id}@${packInfo.sha}` : null;
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (!packInfo || packSkins.has(packKey)) return undefined;
|
|
301
|
+
let live = true;
|
|
302
|
+
const get = lease ? (path, o) => lease.fetch(path, o) : undefined;
|
|
303
|
+
loadPackSkin(packInfo.id, packInfo.sha, get ? { fetch: get } : {}).then((sk) => { if (live) setPackSkins((m) => new Map(m).set(packKey, sk)); });
|
|
304
|
+
return () => { live = false; };
|
|
305
|
+
}, [packKey]);
|
|
306
|
+
const loading = !!packInfo && !packSkins.has(packKey);
|
|
307
|
+
const baseSkin = packInfo ? packSkins.get(packKey) || skinFor("pixel") : skinFor(STYLES.includes(wanted) ? wanted : custom ? wanted : "pixel");
|
|
308
|
+
const zones = !!(baseSkin.pack && baseSkin.pack.stateZones);
|
|
309
|
+
/** The style's identity: a pack's id and sha (a pack replaced is another), or the built-in skin's id. */
|
|
310
|
+
const styleKey = packKey || baseSkin.id;
|
|
311
|
+
// An open session no longer on its office and style (the office went, or its style changed meanwhile): only Cancel.
|
|
312
|
+
const layoutStale = !!layout && (!custom || custom.id !== layout.officeId || custom.skin !== layout.skin || styleKey !== layout.pack || !zones || loading);
|
|
313
|
+
// A stored office's own layout on a zones style (its overrides; while Edit layout is open, the draft).
|
|
314
|
+
const placed = useMemo(() => {
|
|
315
|
+
if (!zones) return { skin: baseSkin, ignored: false };
|
|
316
|
+
if (layout && !layoutStale) return { skin: { ...baseSkin, pack: layout.draft }, ignored: false };
|
|
317
|
+
const ov = custom && custom.overrides;
|
|
318
|
+
if (!ov || ov.pack !== baseSkin.id) return { skin: baseSkin, ignored: false };
|
|
319
|
+
const a = applyOverrides(baseSkin.pack, ov);
|
|
320
|
+
return a.ok ? { skin: { ...baseSkin, pack: a.pack }, ignored: false } : { skin: baseSkin, ignored: true };
|
|
321
|
+
}, [baseSkin, zones, custom, layout, layoutStale]);
|
|
322
|
+
const skin = placed.skin;
|
|
211
323
|
const L = useMemo(() => {
|
|
212
|
-
const
|
|
324
|
+
const all = custom ? membersOf(custom, view) : [...new Map(officeRooms(view, filter).flatMap((r) => r.nodes).map((n) => [n.name, n])).values()];
|
|
325
|
+
const keep = (n) => !(hideAsleep && ASLEEP.has(n.state));
|
|
326
|
+
const nodes = all.filter(keep);
|
|
213
327
|
if (zones) { const z = zonesLayout(skin.pack, nodes, seatsRef.current); seatsRef.current = z.seats; return z; }
|
|
214
|
-
|
|
328
|
+
// The desks wrap at the stage's width at the scale they open on (desksK: a pixel level near one CSS px a unit).
|
|
329
|
+
const deskW = Math.max(240, Math.floor(width / desksK(ratio)));
|
|
330
|
+
if (!custom) return autoLayout(officeRooms(view, filter).map((r) => (hideAsleep ? { ...r, nodes: r.nodes.filter(keep) } : r)), { width: deskW });
|
|
215
331
|
const lay = custom.layout || layoutOk(skin.layout);
|
|
216
|
-
return lay ? customLayout({ ...custom, layout: lay }, nodes) : autoLayout([{ key: custom.id, name: custom.name, lobby: false, nodes }], { width });
|
|
217
|
-
}, [view, filter, custom, width, skin]);
|
|
218
|
-
/** Layout units → CSS px: the zones scene fits the stage (16:9, whole); the desks are drawn at their size. */
|
|
219
|
-
const k = zones ? Math.max(0.2, Math.min(1.25, (width - 2) / L.width, stageH > 0 ? (stageH - 2) / L.height : Infinity)) : 1;
|
|
332
|
+
return lay ? customLayout({ ...custom, layout: lay }, nodes) : autoLayout([{ key: custom.id, name: custom.name, lobby: false, nodes }], { width: deskW });
|
|
333
|
+
}, [view, filter, custom, width, ratio, skin, hideAsleep]);
|
|
220
334
|
const looks = useMemo(() => {
|
|
221
335
|
const work = workOn(traffic);
|
|
222
336
|
return new Map(L.rooms.flatMap((r) => r.desks).map((d) => {
|
|
@@ -235,62 +349,266 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
235
349
|
loop.current?.wake();
|
|
236
350
|
}, [L]);
|
|
237
351
|
|
|
238
|
-
// ──
|
|
239
|
-
//
|
|
352
|
+
// ── The view (#2540): { k, x, y } as the org chart canvas's (fleet-canvas-view.js) — logical units → the stage's CSS
|
|
353
|
+
// px. Set without a render (a ref): the canvas redraws, the overview, the tooltip and Edit layout's overlay follow ──
|
|
354
|
+
const vp = useRef(null), raw = useRef(1); // raw: the wheel's and a pinch's zoom (before the range)
|
|
355
|
+
// touched: a person moved the view (it is kept); fitted: the view is a fit (a resize fits it again, to the new size).
|
|
356
|
+
const touched = useRef(false), fitted = useRef(false), drag = useRef(gestureState());
|
|
357
|
+
const miniViewRef = useRef(null), miniRef = useRef(null), overlayEl = useRef(null), tipDeskRef = useRef(null), pctRef = useRef(null);
|
|
358
|
+
const officeKey = `${custom ? custom.id : AUTO_ID}@${baseSkin.id}`; // what a view is kept for: the office and its style
|
|
359
|
+
const keyRef = useRef(officeKey), saveTimer = useRef(0);
|
|
360
|
+
// The fit mode (Fit: contain; Fill: cover), kept per office and style like the view.
|
|
361
|
+
const [fitRev, setFitRev] = useState(0);
|
|
362
|
+
const fitMode = useMemo(() => store.fitMode(officeKey), [store, officeKey, fitRev]);
|
|
363
|
+
const view0 = () => vp.current || { k: 1, x: 0, y: 0 };
|
|
364
|
+
const flushSave = () => {
|
|
365
|
+
if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = 0; }
|
|
366
|
+
if (touched.current && vp.current) store.saveView(keyRef.current, savedOf(vp.current, sizeRef.current, fitted.current));
|
|
367
|
+
};
|
|
368
|
+
const sizeRef = useRef(size);
|
|
369
|
+
sizeRef.current = size;
|
|
370
|
+
/** Edit layout's overlay over the scene (placed through the CSSOM, never a style attribute: #1300). */
|
|
371
|
+
const placeOverlay = (el) => {
|
|
372
|
+
overlayEl.current = el || null;
|
|
373
|
+
if (!el || !el.style) return;
|
|
374
|
+
const v = view0();
|
|
375
|
+
el.style.setProperty("left", `${v.x}px`); el.style.setProperty("top", `${v.y}px`);
|
|
376
|
+
el.style.setProperty("width", `${L.width * v.k}px`); el.style.setProperty("height", `${L.height * v.k}px`);
|
|
377
|
+
};
|
|
378
|
+
/** The tooltip: what the canvas's card says, above the desk (below it at the top edge), wherever the view puts it. */
|
|
379
|
+
const placeTip = () => {
|
|
380
|
+
const el = tipRef.current, d = tipDeskRef.current;
|
|
381
|
+
if (!el || !d || !el.style) return;
|
|
382
|
+
const v = view0(), p = toScreen(v, d.x, d.y), x = Math.max(4, Math.min(p.x, size.w - 244)), above = p.y > 90;
|
|
383
|
+
el.style.setProperty("transform", above ? `translate(${x}px, ${p.y + 6}px) translateY(-100%)` : `translate(${x}px, ${p.y + d.h * v.k}px)`);
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* Put the view on everything that shows it beside the canvas: the overview's frame (the part of the scene in view;
|
|
387
|
+
* the whole view on its data-k/x/y), the zoom's percentage, the overlay, the tooltip.
|
|
388
|
+
*/
|
|
389
|
+
const mark = () => {
|
|
390
|
+
const v = view0(), m = miniViewRef.current;
|
|
391
|
+
if (m) {
|
|
392
|
+
const f = miniFrame(v, size, L.width, L.height);
|
|
393
|
+
for (const [a, val] of [["x", f.x], ["y", f.y], ["width", f.width], ["height", f.height], ["data-k", v.k], ["data-x", v.x], ["data-y", v.y]]) m.setAttribute(a, String(val));
|
|
394
|
+
}
|
|
395
|
+
const mb = miniRef.current;
|
|
396
|
+
if (mb && mb.style) { // the overview: inside the stage, scaled to it
|
|
397
|
+
const b = miniBox(size, L.width, L.height);
|
|
398
|
+
for (const [a, val] of [["left", b.x], ["top", b.y], ["width", b.w], ["height", b.h]]) mb.style.setProperty(a, `${val}px`);
|
|
399
|
+
}
|
|
400
|
+
const pc = pctRef.current;
|
|
401
|
+
if (pc) { const lb = zoomLabel(v.k); if (pc.textContent !== lb) pc.textContent = lb; pc.setAttribute("aria-label", to("zoomLevel", lb)); }
|
|
402
|
+
if (overlayEl.current) placeOverlay(overlayEl.current);
|
|
403
|
+
placeTip();
|
|
404
|
+
};
|
|
405
|
+
/** …and redraw. */
|
|
406
|
+
const apply = () => {
|
|
407
|
+
mark();
|
|
408
|
+
if (scene.current) { scene.current.dirty = true; if (still) { if (!globalThis.document?.hidden) draw(0); } else loop.current?.wake(); }
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* A new view (kept on the scene: clampPan); `user`: a person moved it (it is kept, a moment later); `fit`: it is a
|
|
412
|
+
* fit (a resize fits it again).
|
|
413
|
+
*/
|
|
414
|
+
const setView = (v, user = true, fit = false) => {
|
|
415
|
+
vp.current = clampPan(v, L.width, L.height, size);
|
|
416
|
+
fitted.current = fit;
|
|
417
|
+
apply();
|
|
418
|
+
if (!user) return;
|
|
419
|
+
touched.current = true;
|
|
420
|
+
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
421
|
+
saveTimer.current = setTimeout(flushSave, 400);
|
|
422
|
+
};
|
|
423
|
+
/** The view an office opens on: its kept one (a kept fit: fitted to this size), else its opening; and whether a fit. */
|
|
424
|
+
const openView = () => {
|
|
425
|
+
const kept = store.view(officeKey);
|
|
426
|
+
if (kept && kept.fit) return { v: fitScene(L.width, L.height, size, fitMode), fit: true };
|
|
427
|
+
if (kept) return { v: viewFromSaved(kept, size), fit: false };
|
|
428
|
+
return { v: openingView(L.width, L.height, size, ratio, zones, fitMode), fit: zones };
|
|
429
|
+
};
|
|
430
|
+
// An office (or style) opened: its kept view, else its opening (zones fitted; desks from the top left). A resize fits
|
|
431
|
+
// a fitted view again (and opens an untouched one again); a view a person moved keeps the same point in the middle.
|
|
432
|
+
// Either way it is then kept on the scene (clampPan): a kept view from a bigger panel cannot leave it off-screen.
|
|
433
|
+
const opened = useRef(null), sized = useRef(null);
|
|
434
|
+
useLayoutEffect(() => {
|
|
435
|
+
const was = sized.current;
|
|
436
|
+
sized.current = { w: size.w, h: size.h, ratio };
|
|
437
|
+
const reopen = () => { const o = openView(); vp.current = o.v; fitted.current = o.fit; };
|
|
438
|
+
if (opened.current !== officeKey) {
|
|
439
|
+
if (opened.current !== null) flushSave();
|
|
440
|
+
opened.current = officeKey; keyRef.current = officeKey; touched.current = false;
|
|
441
|
+
reopen();
|
|
442
|
+
} else if (was && (was.w !== size.w || was.h !== size.h || was.ratio !== ratio)) {
|
|
443
|
+
if (fitted.current) vp.current = fitScene(L.width, L.height, size, fitMode);
|
|
444
|
+
else if (!touched.current) reopen();
|
|
445
|
+
else { const v = vp.current, cx = (was.w / 2 - v.x) / v.k, cy = (was.h / 2 - v.y) / v.k; vp.current = { k: v.k, x: size.w / 2 - cx * v.k, y: size.h / 2 - cy * v.k }; }
|
|
446
|
+
} else if (fitted.current) vp.current = fitScene(L.width, L.height, size, fitMode); // the scene's size changed
|
|
447
|
+
vp.current = clampPan(view0(), L.width, L.height, size);
|
|
448
|
+
raw.current = vp.current.k;
|
|
449
|
+
}, [officeKey, size.w, size.h, ratio, L.width, L.height]);
|
|
450
|
+
useEffect(() => () => flushSave(), []);
|
|
451
|
+
/** Fit (the button, `0`): the office's mode — `mode` when it was just switched. */
|
|
452
|
+
const fit = (mode = fitMode) => { const v = fitScene(L.width, L.height, size, mode); raw.current = v.k; setView(v, true, true); };
|
|
453
|
+
/** Fill on or off: kept for this office, and fitted at once. */
|
|
454
|
+
const toggleFill = () => {
|
|
455
|
+
const next = fitMode === "cover" ? "contain" : "cover";
|
|
456
|
+
store.saveFitMode(officeKey, next); setFitRev((n) => n + 1);
|
|
457
|
+
fit(next);
|
|
458
|
+
};
|
|
459
|
+
/** + / −: the next pixel level about the stage's middle. */
|
|
460
|
+
const zoomStep = (dir) => { const v = view0(), k = stepK(v.k, dir, ratio); raw.current = k; setView(zoomTo(v, k, size.w / 2, size.h / 2)); };
|
|
461
|
+
/** Bring a box (logical units) into view, unchanged when it already is. */
|
|
462
|
+
const show = (b) => {
|
|
463
|
+
const v = view0(), p = toScreen(v, b.x, b.y);
|
|
464
|
+
if (p.x >= 8 && p.y >= 8 && p.x + b.w * v.k <= size.w - 8 && p.y + b.h * v.k <= size.h - 8) return;
|
|
465
|
+
setView(centreOn(v, size, b.x + b.w / 2, b.y + b.h / 2));
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// ── Fullscreen: the API on the container where the page may (its promise, or webkit's events, say how it went), else
|
|
469
|
+
// immersive; leaving undoes whichever it was ──
|
|
470
|
+
const enterFs = () => {
|
|
471
|
+
const el = ooRef.current, d = globalThis.document;
|
|
472
|
+
if (!el || fsRef.current) return;
|
|
473
|
+
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
|
474
|
+
const enabled = d ? (d.fullscreenEnabled ?? d.webkitFullscreenEnabled) : false;
|
|
475
|
+
if (typeof req !== "function" || enabled === false) { setFs("immersive"); return; }
|
|
476
|
+
fsAsked.current = true; fsTarget.current = el;
|
|
477
|
+
let r;
|
|
478
|
+
try { r = req.call(el); } catch { fsAsked.current = false; setFs("immersive"); return; }
|
|
479
|
+
if (r && typeof r.then === "function") {
|
|
480
|
+
r.then(() => { if (ooRef.current === el && fullscreenEl() === el) setFs("native"); },
|
|
481
|
+
() => { if (fsAsked.current && ooRef.current === el) { fsAsked.current = false; setFs("immersive"); } });
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const exitFs = () => {
|
|
485
|
+
fsAsked.current = false;
|
|
486
|
+
const el = ooRef.current, d = globalThis.document;
|
|
487
|
+
if (el && d && fullscreenEl() === el) {
|
|
488
|
+
const ex = d.exitFullscreen || d.webkitExitFullscreen;
|
|
489
|
+
try { const r = typeof ex === "function" ? ex.call(d) : null; if (r && typeof r.catch === "function") r.catch(() => {}); } catch { /* already out */ }
|
|
490
|
+
}
|
|
491
|
+
setFs(null);
|
|
492
|
+
};
|
|
493
|
+
const toggleFs = () => { if (fsRef.current) exitFs(); else enterFs(); };
|
|
494
|
+
const editingRef = useRef(false);
|
|
495
|
+
editingRef.current = editing;
|
|
496
|
+
// The browser's own way out (its Esc, its UI) and its refusals; `f` and Esc; leaving the page leaves fullscreen.
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
const d = globalThis.document;
|
|
499
|
+
if (!d || typeof d.addEventListener !== "function") return undefined;
|
|
500
|
+
const change = () => {
|
|
501
|
+
const on = !!ooRef.current && fullscreenEl() === ooRef.current;
|
|
502
|
+
if (on) fsAsked.current = false;
|
|
503
|
+
setFs((cur) => (on ? "native" : cur === "native" ? null : cur));
|
|
504
|
+
};
|
|
505
|
+
const refused = () => { if (!fsAsked.current) return; fsAsked.current = false; setFs("immersive"); };
|
|
506
|
+
const key = (e) => {
|
|
507
|
+
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
508
|
+
if (e.key === "Escape") { if (fsRef.current === "immersive") { e.preventDefault(); exitFs(); } return; }
|
|
509
|
+
if (e.key !== "f" && e.key !== "F") return;
|
|
510
|
+
const el = ooRef.current, tg = e.target;
|
|
511
|
+
// Only here: from inside the view, or with nothing focused (never while typing, nor over Edit offices' dialog).
|
|
512
|
+
if (!el || typing(tg) || editingRef.current || !(tg === d.body || tg === d.documentElement || tg === d || (tg && el.contains(tg)))) return;
|
|
513
|
+
e.preventDefault();
|
|
514
|
+
toggleFs();
|
|
515
|
+
};
|
|
516
|
+
const evs = [["fullscreenchange", change], ["webkitfullscreenchange", change], ["fullscreenerror", refused], ["webkitfullscreenerror", refused], ["keydown", key]];
|
|
517
|
+
for (const [n, fn] of evs) d.addEventListener(n, fn);
|
|
518
|
+
return () => {
|
|
519
|
+
for (const [n, fn] of evs) d.removeEventListener(n, fn);
|
|
520
|
+
fsAsked.current = false;
|
|
521
|
+
const el = fullscreenEl(), ex = d.exitFullscreen || d.webkitExitFullscreen;
|
|
522
|
+
if (el && (el === ooRef.current || el === fsTarget.current) && typeof ex === "function") {
|
|
523
|
+
try { const r = ex.call(d); if (r && typeof r.catch === "function") r.catch(() => {}); } catch { /* already out */ }
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}, []);
|
|
527
|
+
// Immersive: the dashboard's band the view sits in is lifted over the header, the tabs and the nav (app.css).
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
const root = fs === "immersive" ? globalThis.document?.documentElement : null;
|
|
530
|
+
if (!root || !root.classList) return undefined;
|
|
531
|
+
root.classList.add("oo-immersive");
|
|
532
|
+
return () => root.classList.remove("oo-immersive");
|
|
533
|
+
}, [fs]);
|
|
534
|
+
/** A desk opens its chat: out of fullscreen first (the chat is beside the view, or instead of it). */
|
|
535
|
+
const open = (name) => { if (fsRef.current) exitFs(); onOpen(name); };
|
|
536
|
+
|
|
537
|
+
// ── Drawing: the back and front layers are pictures of the scene at the view's resolution (remade when the office
|
|
538
|
+
// or the zoom changes, never on a pan); each frame draws the matte, the layers, the people and the items in the air ──
|
|
240
539
|
const scene = useRef(null);
|
|
241
540
|
const loop = useRef(null);
|
|
541
|
+
// The matte around the scene: the pack's own, else its background's edge (sampled once), else the skin's (tokens).
|
|
542
|
+
const matte = (skin.pack && skin.pack.matte) || sampledMatte(skin.backdrop) || (skin.matte ? skin.matte({ pal, mode }) : pal.bg);
|
|
543
|
+
// …and behind the canvas too (the stage), so no page grey shows while it is (re)sized.
|
|
544
|
+
useLayoutEffect(() => { const el = stageRef.current; if (el && el.style) el.style.setProperty("background-color", matte); }, [matte]);
|
|
242
545
|
useLayoutEffect(() => {
|
|
243
546
|
const cv = canvasRef.current;
|
|
244
547
|
const ctx = cv && typeof cv.getContext === "function" ? cv.getContext("2d") : null;
|
|
245
548
|
if (!ctx) { scene.current = null; return; }
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const dpr = backingScale(W, H, Math.max(1, Math.min(3, globalThis.devicePixelRatio || 1)));
|
|
249
|
-
const BW = Math.max(1, Math.floor(W * dpr)), BH = Math.max(1, Math.floor(H * dpr));
|
|
549
|
+
// The canvas is the stage: its bitmap at `ratio` px per CSS px (never larger than a browser paints).
|
|
550
|
+
const BW = Math.max(1, Math.floor(size.w * ratio)), BH = Math.max(1, Math.floor(size.h * ratio));
|
|
250
551
|
if (cv.width !== BW || cv.height !== BH) { cv.width = BW; cv.height = BH; }
|
|
251
|
-
cv.style.setProperty("width", `${
|
|
252
|
-
cv.style.setProperty("height", `${
|
|
552
|
+
cv.style.setProperty("width", `${BW / ratio}px`);
|
|
553
|
+
cv.style.setProperty("height", `${BH / ratio}px`);
|
|
253
554
|
const env = { pal, mode, labels: { lobby: to("lobbyRoom"), closed: to("closed"), areas: { desks: to("area_desks"), lounge: to("area_lounge"), alert: to("area_alert"), nap: to("area_nap") } } };
|
|
254
|
-
const scale = dpr * k;
|
|
255
|
-
const layer = (paint) => {
|
|
256
|
-
const c = document.createElement("canvas");
|
|
257
|
-
c.width = BW; c.height = BH;
|
|
258
|
-
const g = c.getContext && c.getContext("2d");
|
|
259
|
-
if (!g) return null;
|
|
260
|
-
g.setTransform(scale, 0, 0, scale, 0, 0);
|
|
261
|
-
g.imageSmoothingEnabled = false;
|
|
262
|
-
paint(g);
|
|
263
|
-
return c;
|
|
264
|
-
};
|
|
265
555
|
const all = L.rooms.flatMap((r) => r.desks);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
556
|
+
/** The layers at `ls` layer px per logical unit. */
|
|
557
|
+
const paint = (ls) => {
|
|
558
|
+
const lw = Math.max(1, Math.ceil(L.width * ls)), lh = Math.max(1, Math.ceil(L.height * ls));
|
|
559
|
+
const layer = (fn) => {
|
|
560
|
+
const c = document.createElement("canvas");
|
|
561
|
+
c.width = lw; c.height = lh;
|
|
562
|
+
const g = c.getContext && c.getContext("2d");
|
|
563
|
+
if (!g) return null;
|
|
564
|
+
g.setTransform(ls, 0, 0, ls, 0, 0);
|
|
565
|
+
g.imageSmoothingEnabled = false;
|
|
566
|
+
fn(g);
|
|
567
|
+
return c;
|
|
568
|
+
};
|
|
569
|
+
const back = layer((g) => {
|
|
570
|
+
g.fillStyle = env.pal.bg; g.fillRect(0, 0, L.width, L.height);
|
|
571
|
+
if (zones) {
|
|
572
|
+
skin.drawBackground(g, L, env);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
skin.drawFloor(g, L, env);
|
|
576
|
+
for (const r of L.rooms) skin.drawRoom(g, r, env);
|
|
577
|
+
for (const d of all) skin.drawDeskBack(g, d, looks.get(d.key), env);
|
|
578
|
+
});
|
|
579
|
+
const front = zones ? null : layer((g) => { for (const d of all) skin.drawDeskFront(g, d, looks.get(d.key), env); });
|
|
580
|
+
return { ls, back, front };
|
|
581
|
+
};
|
|
274
582
|
const shown = new Set([...looks.values()].map((l) => l.state));
|
|
275
|
-
scene.current = { ctx,
|
|
583
|
+
scene.current = { ctx, ratio, BW, BH, paint, layers: null, matte, env, desks: all.filter((d) => d.node), looks, L, skin, zones, dirty: true,
|
|
276
584
|
active: zones ? activeFurniture(skin.pack, shown) : new Set(), lines: bubbleDefaults(), lang: lang() };
|
|
277
585
|
if (still) draw(0); else loop.current?.wake();
|
|
278
|
-
}, [L, looks, skin, pal,
|
|
586
|
+
}, [L, looks, skin, pal, size.w, size.h, ratio, matte]);
|
|
279
587
|
|
|
280
588
|
/** One frame at `ms` (0: still); whether anything is moving (in the air, or someone walking). */
|
|
281
589
|
function draw(ms) {
|
|
282
590
|
const s = scene.current;
|
|
283
591
|
if (!s) return false;
|
|
284
|
-
const { ctx,
|
|
592
|
+
const { ctx, BW, BH, env, skin: sk } = s;
|
|
593
|
+
// The view in bitmap px: a logical unit is `a` px (a whole number at a pixel level), the origin on a whole px.
|
|
594
|
+
const v = view0(), a = v.k * s.ratio, ox = Math.round(v.x * s.ratio), oy = Math.round(v.y * s.ratio);
|
|
595
|
+
const ls = layerScale(a, s.L.width, s.L.height);
|
|
596
|
+
if (!s.layers || s.layers.ls !== ls) s.layers = s.paint(ls);
|
|
597
|
+
const T = () => ctx.setTransform(a, 0, 0, a, ox, oy);
|
|
598
|
+
const blit = (c) => { if (!c) return; ctx.setTransform(a / ls, 0, 0, a / ls, ox, oy); ctx.drawImage(c, 0, 0); T(); };
|
|
285
599
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
286
600
|
ctx.clearRect(0, 0, BW, BH);
|
|
287
|
-
|
|
288
|
-
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
601
|
+
ctx.fillStyle = s.matte; ctx.fillRect(0, 0, BW, BH);
|
|
289
602
|
ctx.imageSmoothingEnabled = false;
|
|
290
|
-
|
|
603
|
+
blit(s.layers.back);
|
|
604
|
+
T();
|
|
605
|
+
const speakers = [], tags = [];
|
|
606
|
+
const up = still ? [] : flights.frame(ms, s.L);
|
|
291
607
|
let walking = false;
|
|
292
608
|
if (s.zones) {
|
|
293
|
-
// Furniture under the people
|
|
609
|
+
// Furniture under the people; the people (nearest last) with the furniture whose depth is "y" among them, by base
|
|
610
|
+
// y (a tie: the item first, the person in front); furniture over them; things in the air; the pack's foreground
|
|
611
|
+
// (door frames, pillars, table fronts); then what must stay readable: crowd counts and name tags (bubbles below).
|
|
294
612
|
const t = ms > 0 ? ms : now();
|
|
295
613
|
const people = s.desks.map((d) => {
|
|
296
614
|
const at = walkers.at(d.node.name, t) || { ...d.seat, walking: false };
|
|
@@ -298,31 +616,43 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
298
616
|
return { d, at, look: s.looks.get(d.key) };
|
|
299
617
|
}).sort((a, b) => a.at.y - b.at.y);
|
|
300
618
|
const furniture = s.skin.pack.furniture;
|
|
301
|
-
|
|
302
|
-
|
|
619
|
+
const band = [...furniture.filter((f) => f.depth === "y").map((f) => ({ y: f.y, k: 0, f })), ...people.map((p) => ({ y: p.at.y, k: 1, p }))]
|
|
620
|
+
.sort((a, b) => a.y - b.y || a.k - b.k);
|
|
621
|
+
for (const f of furniture) if (f.depth !== "y" && f.depth < CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
|
|
622
|
+
for (const { f, p } of band) {
|
|
623
|
+
if (f) { sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env); continue; }
|
|
303
624
|
const box = { ...p.d, x: p.at.x - 44, y: p.at.y - 53, w: 88, h: 53, seat: { x: p.at.x, y: p.at.y } };
|
|
304
|
-
sk.drawCharacter(ctx, box, p.at.walking ? { ...p.look, pose: "walk" } : p.look, ms, env);
|
|
305
|
-
if (!p.at.walking) speakers.push({ look: p.look, x: p.at.x + 10, y: p.at.y - 50 });
|
|
625
|
+
sk.drawCharacter(ctx, box, p.at.walking ? { ...p.look, pose: "walk", heading: p.at.heading } : p.look, ms, env);
|
|
626
|
+
if (!p.at.walking) speakers.push({ look: p.look, x: p.at.x + 10, y: p.at.y - 50, area: p.d.area });
|
|
627
|
+
}
|
|
628
|
+
for (const f of furniture) if (f.depth !== "y" && f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
|
|
629
|
+
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
630
|
+
if (sk.drawForeground) sk.drawForeground(ctx, s.L, env);
|
|
631
|
+
// Who found no spot in an area: a count in its corner (never over its name).
|
|
632
|
+
for (const [a, n] of s.L.crowd || []) if (s.L.areas[a] && sk.drawCrowd) sk.drawCrowd(ctx, s.L.areas[a], n, env);
|
|
633
|
+
// In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
|
|
634
|
+
const over = s.L.over || new Set(), hot = tipNow.current;
|
|
635
|
+
for (const p of people) {
|
|
636
|
+
if (over.has(p.d.area) && p.d.key !== hot && !p.look.match) continue;
|
|
637
|
+
sk.drawTag(ctx, p.at.x, p.at.y, p.look, env);
|
|
638
|
+
tags.push(tagBox(ctx, p.at.x, p.at.y, p.look.label));
|
|
306
639
|
}
|
|
307
|
-
for (const f of furniture) if (f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
|
|
308
|
-
for (const p of people) sk.drawTag(ctx, p.at.x, p.at.y, p.look, env);
|
|
309
640
|
} else {
|
|
310
641
|
for (const d of s.desks) { const look = s.looks.get(d.key); sk.drawCharacter(ctx, d, look, ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12 }); }
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
642
|
+
blit(s.layers.front);
|
|
643
|
+
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
314
644
|
}
|
|
315
|
-
const up = still ? [] : flights.frame(ms, s.L);
|
|
316
|
-
for (const it of up) sk.drawProp(ctx, it, ms, env);
|
|
317
645
|
// Speech bubbles: a short line for the state, now and then (typed out; whole at once when still), placed so no two
|
|
318
646
|
// overlap (placeBubbles; #2431).
|
|
319
647
|
const bubbles = [];
|
|
320
648
|
for (const sp of speakers) {
|
|
321
649
|
if (sp.look.dim || sp.look.pose === "away") continue;
|
|
322
650
|
const b = bubbleAt(sp.look.name, sp.look.state, ms, linesFor(sp.look.state, s.lang, sk.pack, s.lines));
|
|
323
|
-
if (b) bubbles.push({ key: sp.look.name,
|
|
651
|
+
if (b) bubbles.push({ key: sp.look.name, area: sp.area, state: sp.look.state, calling: CALLING.has(sp.look.state), rank: bubbleRank(sp.look.state), shown: b.shown,
|
|
652
|
+
box: bubbleBox(ctx, sp.x, sp.y, b.text), tail: { x: sp.x, y: sp.y }, sp, b });
|
|
324
653
|
}
|
|
325
|
-
|
|
654
|
+
// A crowded area speaks less (thinBubbles: calls, then work, a few at most); no bubble covers a name tag.
|
|
655
|
+
for (const { sp, b, dy } of placeBubbles(thinBubbles(bubbles, s.zones ? s.L.over : null), 22, tags)) sk.drawBubble(ctx, sp.x, sp.y, [...b.text].slice(0, b.shown).join(""), env, -dy);
|
|
326
656
|
return walking || up.length > 0 || flights.size() > 0;
|
|
327
657
|
}
|
|
328
658
|
// The loop: drawing only when the frame (FPS) changed or something is in the air; stopped while the tab is hidden,
|
|
@@ -359,64 +689,151 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
359
689
|
}, []);
|
|
360
690
|
useEffect(() => { loop.current?.wake(); }, [traffic]);
|
|
361
691
|
|
|
362
|
-
//
|
|
692
|
+
// The person hovered or focused: their name tag shows even in a crowd.
|
|
693
|
+
const tipNow = useRef(null);
|
|
694
|
+
tipNow.current = tip;
|
|
695
|
+
useEffect(() => { if (!scene.current) return; scene.current.dirty = true; if (still) draw(0); else loop.current?.wake(); }, [tip]);
|
|
696
|
+
|
|
697
|
+
// Preact owns the markup, not the view: put it back on the overview, the overlay and the tooltip after every render.
|
|
363
698
|
const tipDesk = tip ? desksOf(L).find((d) => d.key === tip) || null : null;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
el.style.setProperty("transform", above ? `translate(${x}px, ${dy + 6}px) translateY(-100%)` : `translate(${x}px, ${dy + tipDesk.h * k}px)`);
|
|
369
|
-
});
|
|
699
|
+
tipDeskRef.current = tipDesk;
|
|
700
|
+
useLayoutEffect(mark);
|
|
701
|
+
// ── Pointing (#2540: through the view): hover (or a first tap) shows a desk's card, a click (or a second tap) opens
|
|
702
|
+
// its chat; a drag on the scene pans it (never on Edit layout's handles: they move), the wheel and a pinch zoom ──
|
|
370
703
|
const deskOf = (e) => {
|
|
371
704
|
const r = canvasRef.current ? canvasRef.current.getBoundingClientRect() : null;
|
|
372
705
|
if (!r) return null;
|
|
373
|
-
const x = (e.clientX - r.left
|
|
374
|
-
// The zones scene draws people where they walk: they are hit there, not at the seat they are heading for
|
|
375
|
-
|
|
706
|
+
const { x, y } = toWorld(view0(), e.clientX - r.left, e.clientY - r.top);
|
|
707
|
+
// The zones scene draws people where they walk: they are hit there, not at the seat they are heading for, and in
|
|
708
|
+
// the box the skin draws them in (an uploaded character's strip, anchor and size: #2513 review), nearest first.
|
|
709
|
+
if (!zones) return deskAt(L, x, y);
|
|
710
|
+
const t = now(), s = scene.current, sk = (s && s.skin) || skin, lk = s ? s.looks : looks;
|
|
711
|
+
return personAt(L.rooms.flatMap((rm) => rm.desks), x, y, (d) => walkers.at(d.node.name, t),
|
|
712
|
+
(d, at) => (sk.charBox && lk.get(d.key) ? sk.charBox(lk.get(d.key), at) : null));
|
|
713
|
+
};
|
|
714
|
+
const g = gestures(drag.current, { el: () => stageRef.current, pan: (dx, dy) => { const v = view0(); setView({ ...v, x: v.x + dx, y: v.y + dy }); },
|
|
715
|
+
pinch: (from, to) => { raw.current = Math.min(OK_MAX, Math.max(OK_MIN, raw.current * (to.dist / from.dist))); setView(pinchOffice(view0(), from, to, raw.current)); } });
|
|
716
|
+
/** Where a drag pans: the canvas, and Edit layout's overlay off its handles (the controls, the bar and the list: never). */
|
|
717
|
+
const onScene = (el) => !!el && (el === canvasRef.current || (typeof el.closest === "function" && !!el.closest(".ol-svg") && !el.closest(".ol-handle")));
|
|
718
|
+
const onDown = (e) => {
|
|
719
|
+
lastType.current = e.pointerType || "mouse";
|
|
720
|
+
if (!onScene(e.target)) return;
|
|
721
|
+
g.down(e);
|
|
722
|
+
if (drag.current.pts.size === 2) raw.current = view0().k; // a pinch starts from the zoom shown
|
|
723
|
+
};
|
|
724
|
+
const onWheel = (e) => {
|
|
725
|
+
if (!onScene(e.target) && !(typeof e.target?.closest === "function" && e.target.closest(".ol-handle"))) return;
|
|
726
|
+
e.preventDefault();
|
|
727
|
+
const r = stageRef.current.getBoundingClientRect(), v = view0();
|
|
728
|
+
raw.current = Math.min(OK_MAX, Math.max(OK_MIN, raw.current * wheelFactor(e)));
|
|
729
|
+
if (raw.current !== v.k) setView(zoomOffice(v, raw.current, e.clientX - r.left, e.clientY - r.top));
|
|
730
|
+
};
|
|
731
|
+
const onKeyDown = (e) => {
|
|
732
|
+
if (e.defaultPrevented) return; // Edit layout's handles move with the arrows
|
|
733
|
+
const tag = e.target && e.target.tagName;
|
|
734
|
+
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
|
735
|
+
const z = zoomKey(e);
|
|
736
|
+
if (z) { e.preventDefault(); if (z === "fit") fit(); else zoomStep(z === "in" ? 1 : -1); return; }
|
|
737
|
+
const dir = PAN_KEYS[e.key];
|
|
738
|
+
if (dir && e.target === canvasRef.current && !e.altKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); const v = view0(); setView({ ...v, x: v.x - dir[0] * 80, y: v.y - dir[1] * 80 }); }
|
|
376
739
|
};
|
|
377
|
-
|
|
740
|
+
// The keyboard's way through: a desk in the list, or a handle of Edit layout, is brought into view when focused.
|
|
741
|
+
const onFocusIn = (e) => {
|
|
742
|
+
const el = e.target;
|
|
743
|
+
if (!el || typeof el.getAttribute !== "function") return;
|
|
744
|
+
const hk = el.getAttribute("data-handle");
|
|
745
|
+
if (hk && layout) { const hd = handlesOf(layout.draft).find((x) => x.key === hk); if (hd) show({ x: hd.x - 12, y: hd.y - 12, w: 24, h: 24 }); return; }
|
|
746
|
+
const name = el.classList && el.classList.contains("oo-desk") ? el.getAttribute("data-name") : null;
|
|
747
|
+
const d = name ? desksOf(L).find((x) => x.node.name === name) : null;
|
|
748
|
+
if (d) show(d);
|
|
749
|
+
};
|
|
750
|
+
const onMove = (e) => { if (e.pointerType === "touch" || g.busy()) return; const d = deskOf(e); const key = d ? d.key : null; if (key !== tip) setTip(key); };
|
|
378
751
|
const onClick = (e) => {
|
|
379
752
|
const d = deskOf(e);
|
|
380
753
|
if (!d || !d.node.i) { setTip(null); return; }
|
|
381
754
|
if (lastType.current === "touch" && tip !== d.key) { setTip(d.key); return; } // a first tap shows; a second opens
|
|
382
|
-
|
|
755
|
+
open(d.node.name);
|
|
756
|
+
};
|
|
757
|
+
const onMini = (e) => {
|
|
758
|
+
const w = miniPoint(miniRef.current ? miniRef.current.getBoundingClientRect() : null, { x: 0, y: 0, w: L.width, h: L.height }, e.clientX, e.clientY);
|
|
759
|
+
if (w) setView(centreOn(view0(), size, w.x, w.y));
|
|
383
760
|
};
|
|
384
761
|
|
|
385
762
|
const auto = !custom;
|
|
763
|
+
// Edit layout: an admin, a stored office, a zones style (built in, or a pack loaded here); not while the offices'
|
|
764
|
+
// editor is open, nor a second session.
|
|
765
|
+
const canLayout = canEdit && storedRev !== null && !!custom && zones && !loading && baseSkin.id === custom.skin && !editing && !layout;
|
|
766
|
+
const openLayout = () => {
|
|
767
|
+
setLayout({ id: ++layoutSeq.current, officeId: custom.id, skin: custom.skin, pack: styleKey, base: baseSkin.pack, start: skin.pack, draft: skin.pack, rev: storedRev, list: stored });
|
|
768
|
+
setTip(null);
|
|
769
|
+
};
|
|
770
|
+
const closeLayout = () => setLayout(null);
|
|
771
|
+
const onDraft = (draft) => setLayout((cur) => (cur ? { ...cur, draft } : cur));
|
|
772
|
+
// Save: the session's list, this office's overrides replaced (none: back to the style's own), with the session's rev.
|
|
773
|
+
// A change made meanwhile (409) reads the list again: the session takes it (the view too), the draft is kept, and
|
|
774
|
+
// Save again — the person's call, told so — puts it on that list.
|
|
775
|
+
const saveLayout = async (ov) => {
|
|
776
|
+
const s = layoutRef.current;
|
|
777
|
+
const mine = s && s.list.find((o) => o.id === s.officeId);
|
|
778
|
+
if (!s || !mine || mine.skin !== s.skin) return { ok: false, message: t("orgl.stale") };
|
|
779
|
+
const list = s.list.map((o) => (o.id === s.officeId ? (({ overrides: _gone, ...rest }) => (ov ? { ...rest, overrides: ov } : rest))(o) : o)).map(officeForSave);
|
|
780
|
+
const sent = ask();
|
|
781
|
+
const r = await store.saveOffices(list, s.rev);
|
|
782
|
+
if (r.ok) { adopt({ rev: r.rev, offices: r.offices }, sent); return { ok: true }; }
|
|
783
|
+
if (r.status === 409) {
|
|
784
|
+
const at = ask(); const cur = adopt(await store.offices(), at);
|
|
785
|
+
if (cur) setLayout((c) => (c && c.id === s.id ? { ...c, list: cur.offices, rev: cur.rev } : c));
|
|
786
|
+
return { ok: false, message: t("orgl.changed") };
|
|
787
|
+
}
|
|
788
|
+
return { ok: false, message: r.status === 429 ? t("orgl.rateLimited") : t("orgl.failed", r.error || r.status) };
|
|
789
|
+
};
|
|
790
|
+
const styleName = (id) => (STYLES.includes(id) ? to(`style_${id}`) : (packs.find((pk) => pk.id === id) || { name: id }).name);
|
|
386
791
|
const f = filterOk(filter);
|
|
387
792
|
const opts = teamOptions(view);
|
|
388
793
|
const teamValue = (opts.find((o) => o.team === f.team) || opts[0]).value;
|
|
389
794
|
const setF = (next) => { const v = filterOk(next); store.saveFilter(v); setFilter(v); };
|
|
390
|
-
|
|
795
|
+
// While Edit layout is open the office stays (its picker is disabled; this is the guard behind it).
|
|
796
|
+
const pick = (id) => { if (layoutRef.current) return; store.saveChoice(id); setChoice(id); setTip(null); };
|
|
391
797
|
const everyone = useMemo(() => { const m = new Map(); for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!m.has(n.name)) m.set(n.name, n); return [...m.values()]; }, [view]);
|
|
392
798
|
const desks = desksOf(L);
|
|
393
799
|
const tipText = tipDesk ? nodeText(tipDesk.node) : null;
|
|
394
|
-
|
|
800
|
+
// The overview: the scene's rooms (or areas) and a dot for each person (a call in its colour), the view's frame on it.
|
|
801
|
+
const miniRects = zones ? Object.values(L.areas || {}).map((a) => ({ key: a.name, ...a.rect })) : L.rooms.map((r) => ({ key: r.key, x: r.x, y: r.y, w: r.w, h: r.h }));
|
|
802
|
+
const dotR = Math.max(L.width, L.height) / 90;
|
|
803
|
+
const officeSelect = (cls) => html`<select class=${`tb-select ${cls}`} value=${layout ? layout.officeId : custom ? custom.id : AUTO_ID} disabled=${!!layout}
|
|
804
|
+
aria-label=${to("office")} onChange=${(e) => pick(e.target.value)}>
|
|
805
|
+
<option value=${AUTO_ID}>${to("allOffice")}</option>
|
|
806
|
+
${stored.map((o) => html`<option key=${o.id} value=${o.id}>${o.name}</option>`)}
|
|
807
|
+
</select>`;
|
|
808
|
+
return html`<div class=${`oo${fs ? ` fs ${fs}` : ""}`} ref=${ooRef}>
|
|
395
809
|
<div class="oo-bar">
|
|
396
|
-
<label class="oo-pick"><span class="oo-pick-label">${to("office")}</span>
|
|
397
|
-
<select class="tb-select" value=${custom ? custom.id : AUTO_ID} onChange=${(e) => pick(e.target.value)}>
|
|
398
|
-
<option value=${AUTO_ID}>${to("allOffice")}</option>
|
|
399
|
-
${stored.map((o) => html`<option key=${o.id} value=${o.id}>${o.name}</option>`)}
|
|
400
|
-
</select></label>
|
|
810
|
+
<label class="oo-pick"><span class="oo-pick-label">${to("office")}</span>${officeSelect("oo-office")}</label>
|
|
401
811
|
${auto ? html`<label class="oo-pick"><span class="oo-pick-label">${to("styleLabel")}</span>
|
|
402
|
-
<select class="tb-select" value=${skin.id} onChange=${(e) => { store.saveSkin(e.target.value); setStyle(e.target.value); setTip(null); }}>
|
|
812
|
+
<select class="tb-select" value=${STYLES.includes(style) || packs.some((pk) => pk.id === style) ? style : skin.id} onChange=${(e) => { store.saveSkin(e.target.value); setStyle(e.target.value); setTip(null); }}>
|
|
403
813
|
${STYLES.map((id) => html`<option key=${id} value=${id}>${to(`style_${id}`)}</option>`)}
|
|
814
|
+
${packs.map((pk) => html`<option key=${`pack:${pk.id}`} value=${pk.id}>${pk.name}</option>`)}
|
|
404
815
|
</select></label>` : null}
|
|
405
816
|
${auto ? html`<label class="oo-pick"><span class="oo-pick-label">${to("show")}</span>
|
|
406
817
|
<select class="tb-select" value=${teamValue} onChange=${(e) => { const o = opts.find((x) => x.value === e.target.value); setF({ ...f, team: o ? o.team : "" }); }}>
|
|
407
818
|
${opts.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}
|
|
408
819
|
</select></label>
|
|
409
820
|
<${People} nodes=${everyone} pinned=${f.pinned} onChange=${(pinned) => setF({ ...f, pinned })} />` : null}
|
|
410
|
-
|
|
821
|
+
<label class="check oo-hide-asleep"><input type="checkbox" checked=${hideAsleep} onChange=${() => { const v = !hideAsleep; store.saveHideAsleep(v); setHideAsleep(v); setTip(null); }} /><span>${to("hideAsleep")}</span></label>
|
|
822
|
+
${custom && baseSkin.id !== custom.skin && !loading ? html`<span class="muted oo-note">${to("skinUnknown", styleName(custom.skin))}</span>` : null}
|
|
823
|
+
${placed.ignored ? html`<span class="muted oo-note">${to("layoutIgnored")}</span>` : null}
|
|
411
824
|
${L.unseated ? html`<span class="muted oo-note">${to("unseated", L.unseated)}</span>` : null}
|
|
412
|
-
${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => setEditing(true)}>${t("orge.edit")}</button>` : null}
|
|
825
|
+
${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => setEditing(true)} disabled=${!!layout}>${t("orge.edit")}</button>` : null}
|
|
826
|
+
${canLayout ? html`<button type="button" class="btn btn-sm oo-layout" onClick=${openLayout}>${t("orgl.edit")}</button>` : null}
|
|
413
827
|
</div>
|
|
414
|
-
${editing ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store}
|
|
828
|
+
${editing ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
|
|
415
829
|
ask=${ask} current=${() => shown.current} onSaved=${adopt} onClose=${() => setEditing(false)} />` : null}
|
|
416
830
|
${desks.length || L.rooms.length ? null : html`<p class="note oo-empty">${to("nobody")} ${auto && (f.team || f.pinned.length) ? html`<button type="button" class="btn btn-sm" onClick=${() => setF({ team: "", pinned: [] })}>${to("everyone")}</button>` : null}</p>`}
|
|
417
|
-
<div class
|
|
418
|
-
|
|
419
|
-
|
|
831
|
+
<div class=${`oo-stage${layout ? " editing" : ""}`} ref=${stageRef} onPointerDown=${onDown} onPointerMove=${g.move} onPointerUp=${g.up} onPointerCancel=${g.up}
|
|
832
|
+
onWheel=${onWheel} onKeyDown=${onKeyDown} onFocusIn=${onFocusIn} onClickCapture=${g.clickCapture}>
|
|
833
|
+
<canvas ref=${canvasRef} class=${`oo-canvas${tipDesk && tipDesk.node.i ? " hot" : ""}`} role="application" tabindex="0" aria-label=${to("label")}
|
|
834
|
+
onPointerMove=${onMove} onPointerLeave=${() => setTip(null)} onClick=${onClick}></canvas>
|
|
835
|
+
${layout ? html`<${LayoutEditor} key=${layout.id} base=${layout.base} start=${layout.start} place=${placeOverlay}
|
|
836
|
+
stale=${layoutStale ? t("orgl.stale") : ""} onChange=${onDraft} onSave=${saveLayout} onClose=${closeLayout} />` : null}
|
|
420
837
|
${tipText ? html`<div ref=${tipRef} class="oo-tip" role="tooltip">
|
|
421
838
|
<strong>${tipText.primary}</strong>${tipText.secondary ? html` <span class="muted mono">${tipText.secondary}</span>` : null}
|
|
422
839
|
<div>${tipText.sub}</div><div class="muted">${caption(officePose(tipDesk.node.state), tipDesk.node.state)}</div>
|
|
@@ -426,9 +843,28 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
|
|
|
426
843
|
const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state);
|
|
427
844
|
return html`<li key=${d.key}>${d.node.i
|
|
428
845
|
? html`<button type="button" class="oo-desk" data-name=${d.node.name} aria-label=${`${nt.full} · ${cap}. ${t("orgc.openChat")}`}
|
|
429
|
-
onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() =>
|
|
846
|
+
onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() => open(d.node.name)}>${nt.primary}</button>`
|
|
430
847
|
: html`<span class="oo-desk" data-name=${d.node.name}>${nt.full}</span>`}</li>`;
|
|
431
848
|
})}</ul>
|
|
849
|
+
<div class="oc-zoom oo-zoom" role="group" aria-label=${t("orgc.view")}>
|
|
850
|
+
<output ref=${pctRef} class="oo-zoom-pct"></output>
|
|
851
|
+
<button type="button" class="icon-btn oo-zoom-in" aria-label=${t("orgc.zoomIn")} title=${t("orgc.zoomIn")} onClick=${() => zoomStep(1)}><${Icon} name="plus" size=${16} /></button>
|
|
852
|
+
<button type="button" class="icon-btn oo-zoom-out" aria-label=${t("orgc.zoomOut")} title=${t("orgc.zoomOut")} onClick=${() => zoomStep(-1)}><${Icon} name="minus" size=${16} /></button>
|
|
853
|
+
<button type="button" class="icon-btn oo-fit" aria-label=${to("fit")} title=${to("fit")} onClick=${() => fit()}><${Icon} name="fitwidth" size=${16} /></button>
|
|
854
|
+
<button type="button" class=${`icon-btn oo-fill${fitMode === "cover" ? " on" : ""}`} aria-pressed=${fitMode === "cover" ? "true" : "false"} aria-label=${to("fillTitle")}
|
|
855
|
+
title=${to("fillTitle")} onClick=${toggleFill}>${to("fill")}</button>
|
|
856
|
+
<button type="button" class="icon-btn oo-fs" aria-label=${to(fs ? "exitFullscreen" : "fullscreen")} title=${to(fs ? "exitFullscreen" : "fullscreen")} onClick=${toggleFs}><${Icon} name=${fs ? "fullscreenexit" : "fullscreen"} size=${16} /></button>
|
|
857
|
+
</div>
|
|
858
|
+
${fs ? html`<div class="oo-fs-bar">${officeSelect("oo-fs-office")}</div>` : null}
|
|
859
|
+
<svg ref=${miniRef} class="oc-mini oo-mini" viewBox=${`0 0 ${Math.max(1, L.width)} ${Math.max(1, L.height)}`} aria-hidden="true" onClick=${onMini}>
|
|
860
|
+
<title>${to("overview")}</title>
|
|
861
|
+
<rect class="oo-mini-matte" x=${-L.width} y=${-L.height} width=${L.width * 3} height=${L.height * 3} fill=${matte} />
|
|
862
|
+
<rect class="oo-mini-scene" x="0" y="0" width=${L.width} height=${L.height} />
|
|
863
|
+
${miniRects.map((r) => html`<rect key=${r.key} class="oo-mini-room" x=${r.x} y=${r.y} width=${r.w} height=${r.h} />`)}
|
|
864
|
+
${desks.map((d) => { const at = zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 };
|
|
865
|
+
return html`<circle key=${d.key} class=${`oo-mini-dot st-${d.node.state}${q && hits(d.node, q) ? " match" : ""}`} cx=${at.x} cy=${at.y} r=${dotR} />`; })}
|
|
866
|
+
<rect ref=${miniViewRef} class="oc-mini-view oo-mini-view" />
|
|
867
|
+
</svg>
|
|
432
868
|
</div>
|
|
433
869
|
</div>`;
|
|
434
870
|
}
|