@songsid/agend 2.3.0-beta.22 → 2.3.0-beta.24
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/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/dist/agent-cli.js +4 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +1 -0
- package/dist/agent-endpoint.js +4 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.d.ts +5 -0
- package/dist/agent-whats-new.js +16 -1
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +7 -0
- package/dist/auth-api.js +54 -21
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/codex-package.d.ts +30 -0
- package/dist/backend/codex-package.js +133 -0
- package/dist/backend/codex-package.js.map +1 -0
- package/dist/backend/codex.d.ts +2 -0
- package/dist/backend/codex.js +9 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.js +3 -1
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -1
- package/dist/backend/kiro.js +102 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode-permission.js +1 -1
- package/dist/backend/opencode-permission.js.map +1 -1
- package/dist/backend/opencode.js +2 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/binary-probe.js +1 -1
- package/dist/bug-report-cli.js +11 -10
- package/dist/bug-report-cli.js.map +1 -1
- package/dist/bug-report.js +11 -10
- package/dist/bug-report.js.map +1 -1
- package/dist/bundled-skills.d.ts +7 -3
- package/dist/bundled-skills.js +16 -7
- package/dist/bundled-skills.js.map +1 -1
- package/dist/channel/adapters/discord.js +3 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-server.js +12 -4
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +3 -1
- package/dist/channel/mcp-tools.js +36 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +54 -0
- package/dist/child-process.js.map +1 -0
- package/dist/cli.js +188 -204
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +1 -1
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +20 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +8 -0
- package/dist/daemon.js +31 -8
- package/dist/daemon.js.map +1 -1
- package/dist/darwin-memory.js +1 -1
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +42 -0
- package/dist/doctor.js.map +1 -1
- package/dist/export-import.js +4 -3
- package/dist/export-import.js.map +1 -1
- package/dist/fleet-level-config.d.ts +1 -1
- package/dist/fleet-level-config.js +2 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +45 -0
- package/dist/fleet-manager.js +324 -36
- package/dist/fleet-manager.js.map +1 -1
- package/dist/full-restart.d.ts +5 -5
- package/dist/full-restart.js +5 -5
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +48 -10
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +7 -8
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +319 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +529 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +17 -4
- package/dist/general-knowledge/steering/core-rules.md +1 -1
- package/dist/instance-config-impact.js +10 -3
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.js +4 -4
- package/dist/locale.js +296 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.js +1 -1
- package/dist/login-shell.js +3 -1
- package/dist/login-shell.js.map +1 -1
- package/dist/ls-rows.js +1 -1
- package/dist/needs-you-hub.d.ts +98 -1
- package/dist/needs-you-hub.js +313 -44
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +5 -2
- package/dist/needs-you-live.js +6 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/needs-you.d.ts +58 -3
- package/dist/needs-you.js +36 -3
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -3
- package/dist/public-link-progress.d.ts +12 -1
- package/dist/public-link-progress.js +5 -10
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-tunnel-config.d.ts +5 -0
- package/dist/public-tunnel-config.js +45 -1
- package/dist/public-tunnel-config.js.map +1 -1
- package/dist/public-web-link.js +6 -5
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart.js +15 -9
- package/dist/quickstart.js.map +1 -1
- package/dist/resource-report.js +1 -1
- package/dist/scheduler/task-board.d.ts +41 -1
- package/dist/scheduler/task-board.js +152 -5
- package/dist/scheduler/task-board.js.map +1 -1
- package/dist/sd-notify.js +2 -1
- package/dist/sd-notify.js.map +1 -1
- package/dist/service-installer.d.ts +69 -0
- package/dist/service-installer.js +182 -0
- package/dist/service-installer.js.map +1 -1
- package/dist/service-owner.d.ts +44 -0
- package/dist/service-owner.js +48 -0
- package/dist/service-owner.js.map +1 -0
- package/dist/service-plan.d.ts +8 -0
- package/dist/service-plan.js +13 -0
- package/dist/service-plan.js.map +1 -1
- package/dist/settings-api.d.ts +4 -0
- package/dist/settings-api.js +21 -3
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +88 -4
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +2 -0
- package/dist/settings-confirmation.js +14 -8
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-control.js +2 -1
- package/dist/settings-control.js.map +1 -1
- package/dist/settings-effect.js +2 -0
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +2 -0
- package/dist/settings-http-confirmation.js +3 -2
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-request-capability.d.ts +4 -1
- package/dist/settings-request-capability.js +11 -3
- package/dist/settings-request-capability.js.map +1 -1
- package/dist/setup-auth.d.ts +1 -0
- package/dist/setup-auth.js +2 -1
- package/dist/setup-auth.js.map +1 -1
- package/dist/setup-host.d.ts +1 -0
- package/dist/setup-host.js +7 -3
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +1 -1
- package/dist/setup-wizard.js.map +1 -1
- package/dist/task-board-summary.d.ts +101 -0
- package/dist/task-board-summary.js +177 -0
- package/dist/task-board-summary.js.map +1 -0
- package/dist/task-board-waker.d.ts +8 -1
- package/dist/task-board-waker.js +15 -6
- package/dist/task-board-waker.js.map +1 -1
- package/dist/tmux-control.js +1 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +13 -3
- package/dist/tmux-exec-helper.js +34 -29
- package/dist/tmux-exec-helper.js.map +1 -1
- package/dist/tmux-manager.js +1 -1
- package/dist/tmux-read.js +1 -1
- package/dist/tmux-read.js.map +1 -1
- package/dist/topic-commands.d.ts +3 -0
- package/dist/topic-commands.js +29 -18
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +5 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +2 -0
- package/dist/transcript-sources.js +4 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared-install.js +3 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +1 -1
- package/dist/tunnel/cloudflared.js +51 -10
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/named-diagnostics.d.ts +13 -0
- package/dist/tunnel/named-diagnostics.js +52 -0
- package/dist/tunnel/named-diagnostics.js.map +1 -0
- package/dist/tunnel/types.d.ts +2 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/ui/app-effects.js +10 -4
- package/dist/ui/chat-strings.js +3 -0
- package/dist/ui/chat-thread.js +14 -7
- package/dist/ui/effect-trial-offer.js +2 -31
- package/dist/ui/effects-waker.js +2 -70
- package/dist/ui/fleet-task-card.js +5 -5
- package/dist/ui/fleet-task-common.js +43 -9
- package/dist/ui/fleet-task-work.js +107 -0
- package/dist/ui/fleet-tasks.js +46 -12
- package/dist/ui/image-lightbox.js +39 -1
- package/dist/ui/image-zoom.js +242 -0
- package/dist/ui/panel-chat.js +24 -13
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +6 -2
- package/dist/ui/panel-needs.js +21 -6
- package/dist/ui/panel-settings.js +35 -13
- package/dist/ui/preview-panel.js +2 -1
- package/dist/ui/search-dialog.js +2 -1
- package/dist/ui/settings-appearance.js +44 -10
- package/dist/ui/settings-dialogs.js +33 -17
- package/dist/ui/settings-discord.js +6 -2
- package/dist/ui/settings-guests.js +2 -1
- package/dist/ui/settings-model.js +2 -11
- package/dist/ui/settings-mods.js +8 -3
- package/dist/ui/settings-strings.js +32 -0
- package/dist/ui/settings-token.js +3 -1
- package/dist/ui/settings-web-authority.js +6 -3
- package/dist/ui/settings-wizard.js +13 -7
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-needs.js +2 -2
- package/dist/ui/shared/app-store.js +2 -2
- package/dist/ui/shared/app.css +93 -32
- package/dist/ui/shared/app.js +5 -1
- package/dist/ui/shared/effect-app-runtime.js +7 -5
- package/dist/ui/shared/effect-chat-runtime.js +2 -7
- package/dist/ui/shared/effect-cursor.js +3 -2
- package/dist/ui/shared/effect-display-metadata.js +39 -0
- package/dist/ui/shared/effect-registry.js +4 -2
- package/dist/ui/shared/effect-runtime-loader.js +3 -1
- package/dist/ui/shared/effect-stock-trust.js +5 -5
- package/dist/ui/shared/feature-flags.js +127 -0
- package/dist/ui/shared/identity-color.js +6 -3
- package/dist/ui/shared/instance-nav.js +3 -2
- package/dist/ui/shared/panel-view.js +14 -29
- package/dist/ui/shared/text-undo.js +210 -0
- package/dist/ui/shared/ui-dialog.js +4 -3
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/usage-dialog.js +29 -8
- package/dist/ui/shared/view-strings.js +2 -4
- package/dist/ui/web-terminal/terminal.html +10 -10
- package/dist/ui/web-terminal/terminal.js +58 -12
- package/dist/update-dispatch.js +1 -1
- package/dist/update-launch.d.ts +1 -1
- package/dist/update-launch.js +1 -2
- package/dist/update-launch.js.map +1 -1
- package/dist/update-preflight.js +1 -1
- package/dist/usage/providers.js +1 -1
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.js +1 -1
- package/dist/web-api.d.ts +6 -0
- package/dist/web-api.js +163 -21
- package/dist/web-api.js.map +1 -1
- package/dist/web-app-icon-worker.js +2 -1
- package/dist/web-app-icon-worker.js.map +1 -1
- package/dist/web-app-icon.d.ts +10 -8
- package/dist/web-app-icon.js +14 -13
- package/dist/web-app-icon.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +1 -0
- package/dist/web-appearance-tools.js +7 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance.d.ts +4 -10
- package/dist/web-appearance.js +4 -3
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-auth.d.ts +8 -4
- package/dist/web-auth.js +28 -17
- package/dist/web-auth.js.map +1 -1
- package/dist/web-chat-history.d.ts +14 -0
- package/dist/web-chat-history.js +26 -1
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-cookie-names.d.ts +5 -0
- package/dist/web-cookie-names.js +12 -0
- package/dist/web-cookie-names.js.map +1 -0
- package/dist/web-experimental-http.d.ts +2 -0
- package/dist/web-experimental-http.js +12 -0
- package/dist/web-experimental-http.js.map +1 -0
- package/dist/web-experimental.d.ts +11 -0
- package/dist/web-experimental.js +49 -0
- package/dist/web-experimental.js.map +1 -0
- package/dist/web-guest-access.d.ts +1 -0
- package/dist/web-guest-access.js +4 -2
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-mod-store.d.ts +2 -0
- package/dist/web-mod-store.js +10 -0
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-png.d.ts +4 -0
- package/dist/web-png.js +10 -2
- package/dist/web-png.js.map +1 -1
- package/dist/web-preview.d.ts +2 -0
- package/dist/web-preview.js +2 -0
- package/dist/web-preview.js.map +1 -1
- package/dist/web-returning-device.d.ts +4 -2
- package/dist/web-returning-device.js +12 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-settings-patch.d.ts +2 -1
- package/dist/web-settings-patch.js +5 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +1 -0
- package/dist/web-settings-tools.js +14 -10
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-terminal.js +1 -1
- package/dist/web-theme-store.d.ts +2 -2
- package/dist/web-theme-store.js +6 -3
- package/dist/web-theme-store.js.map +1 -1
- package/package.json +11 -11
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// #2246 step 3: who works on what on the task board — each instance's cards as its `task mine` reads them (GET
|
|
2
|
+
// /ui/tasks/work, the server's myWork): the board's Instance list, a drawer by team as taskhall's, and the block on an
|
|
3
|
+
// instance's Details page. A card opens on the board. No style attribute anywhere (the CSP).
|
|
4
|
+
import { html, useEffect, useState } from "/assets/app-html.js";
|
|
5
|
+
import { t } from "/assets/app-i18n.js";
|
|
6
|
+
import { appStore, useStore } from "/assets/app-store.js";
|
|
7
|
+
import { Dialog } from "/assets/ui-dialog.js";
|
|
8
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
9
|
+
import { Skeleton } from "/assets/ui-states.js";
|
|
10
|
+
import { GroupMark, InstanceAvatar, dotLabel, dotState, groupKind, groupLabel, groupOf, namesOf, namesStore } from "/assets/instance-nav.js";
|
|
11
|
+
import { Who, boardApi, errorText } from "./fleet-task-common.js";
|
|
12
|
+
|
|
13
|
+
const LABEL = { back: "tasks.workSentBack", doing: "tasks.workDoing", blocked: "tasks.workBlocked", review: "tasks.workReview", main: "tasks.workMain", ready: "tasks.workReady", waiting: "tasks.workWaiting" };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An instance's work as lines, in taskhall's order: what it works on, is blocked on, waits on the person for, the main
|
|
17
|
+
* tasks it coordinates, what it can start, what waits for other cards. A card sent back to it says so instead.
|
|
18
|
+
*/
|
|
19
|
+
export function workLines(w) {
|
|
20
|
+
const back = new Set(w.sentBack || []), unread = new Set(w.newComments || []);
|
|
21
|
+
const line = (kind, card, note = "") => ({ kind: kind !== "main" && back.has(card.id) ? "back" : kind, card, note, unread: unread.has(card.id) });
|
|
22
|
+
return [
|
|
23
|
+
...(w.doing || []).map((c) => line("doing", c)),
|
|
24
|
+
...(w.blocked || []).map((c) => line("blocked", c)),
|
|
25
|
+
...(w.review || []).map((c) => line("review", c)),
|
|
26
|
+
...(w.mains || []).map((c) => line("main", c, c.progress && c.progress.total ? t("tasks.workSubs", c.progress.done, c.progress.total) : "")),
|
|
27
|
+
...(w.ready || []).map((c) => line("ready", c, c.assignee ? "" : t("tasks.workClaimable"))),
|
|
28
|
+
...(w.waiting || []).map((c) => line("waiting", c, t("tasks.workWaitsFor", (c.unmetDeps || []).join(t("tasks.workJoin"))))),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function WorkLines({ lines, onOpen }) {
|
|
33
|
+
if (!lines.length) return html`<p class="muted tw-none">${t("tasks.workNone")}</p>`;
|
|
34
|
+
return html`<ul class="tw-lines">${lines.map((l) => html`<li key=${`${l.kind}:${l.card.id}`}>
|
|
35
|
+
<button type="button" class="tw-line" title=${`${l.card.id} ${l.card.title}`} onClick=${() => onOpen(l.card.id)}>
|
|
36
|
+
<span class=${`tw-kind k-${l.kind}`}>${t(LABEL[l.kind])}</span><span class="tb-code">${l.card.id}</span><span class="tw-title">${l.card.title}</span>
|
|
37
|
+
${l.note ? html`<span class="muted tw-note">${l.note}</span>` : null}
|
|
38
|
+
${l.unread ? html`<span class="tw-unread" title=${t("tasks.workNewComments")} aria-label=${t("tasks.workNewComments")}><${Icon} name="chat" size=${13} /></span>` : null}
|
|
39
|
+
</button></li>`)}</ul>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const OUTSIDE = "\u0000outside";
|
|
43
|
+
/** Groups as the sidebar orders them: teams, then tags, then the rest; names nobody in the fleet has last. */
|
|
44
|
+
const groupRank = (g) => (g === OUTSIDE ? 3 : groupKind(g) === "team" ? 0 : groupKind(g) === "tag" ? 1 : 2);
|
|
45
|
+
|
|
46
|
+
/** The board's Instance list: every instance by team, working ones first, each with its cards. Read again on each board change. */
|
|
47
|
+
export function InstancesDrawer({ tick, onClose, onOpen }) {
|
|
48
|
+
const { instances, exec, awaiting } = useStore(appStore);
|
|
49
|
+
useStore(namesStore); // the sidebar's Names choice: display name or instance name first
|
|
50
|
+
const [work, setWork] = useState(null);
|
|
51
|
+
const [error, setError] = useState("");
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let live = true;
|
|
54
|
+
boardApi.work().then((d) => { if (live) { setWork(d.instances || []); setError(""); } }, (e) => { if (live) setError(errorText(e)); });
|
|
55
|
+
return () => { live = false; };
|
|
56
|
+
}, [tick]);
|
|
57
|
+
const byName = new Map((instances || []).map((i) => [i.name, i]));
|
|
58
|
+
const rows = (work || []).map((w) => {
|
|
59
|
+
const it = byName.get(w.name);
|
|
60
|
+
return { w, it, state: it ? dotState(it, exec[w.name], awaiting[w.name]) : null, lines: workLines(w) };
|
|
61
|
+
});
|
|
62
|
+
// Working first, then those with cards, then the running ones; by name.
|
|
63
|
+
const rank = (r) => (r.state === "working" ? 0 : r.lines.length ? 1 : r.it && r.it.status === "running" ? 2 : 3);
|
|
64
|
+
rows.sort((a, b) => rank(a) - rank(b) || a.w.name.localeCompare(b.w.name));
|
|
65
|
+
const groups = new Map();
|
|
66
|
+
for (const r of rows) {
|
|
67
|
+
const g = r.it ? groupOf(r.it) : OUTSIDE;
|
|
68
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
69
|
+
groups.get(g).push(r);
|
|
70
|
+
}
|
|
71
|
+
const ordered = [...groups].sort(([a], [b]) => groupRank(a) - groupRank(b));
|
|
72
|
+
const working = rows.filter((r) => r.state === "working").length;
|
|
73
|
+
return html`<${Dialog} title=${t("tasks.instances")} onClose=${onClose} wide drawer>
|
|
74
|
+
<div class="tw">
|
|
75
|
+
${work ? html`<p class="muted tw-head">${t("tasks.instancesHead", rows.length, working)}</p>` : null}
|
|
76
|
+
${error ? html`<p class="tw-error" role="alert">${t("tasks.workFailed", error)}</p>` : null}
|
|
77
|
+
${!work && !error ? html`<${Skeleton} lines=${4} />` : null}
|
|
78
|
+
${ordered.map(([g, list]) => html`<section key=${g} class="tw-group">
|
|
79
|
+
<h3 class="tw-group-head">${g === OUTSIDE ? t("tasks.workOutside") : html`<${GroupMark} group=${g} />${groupLabel(g)}`}<span class="tb-count">${list.length}</span></h3>
|
|
80
|
+
${list.map((r) => html`<div key=${r.w.name} class="tw-inst">
|
|
81
|
+
<div class="tw-inst-head">
|
|
82
|
+
${r.it ? html`<${InstanceAvatar} it=${r.it} state=${r.state} />` : html`<${Who} name=${r.w.name} />`}
|
|
83
|
+
${r.it ? (() => { const n = namesOf(r.it); return html`<span class="tw-name" title=${r.w.name}>${n.primary}</span>${n.secondary ? html`<span class="muted tw-alt">${n.secondary}</span>` : null}`; })() : null}
|
|
84
|
+
${r.state ? html`<span class="muted tw-state">${dotLabel(r.state)}</span>` : null}
|
|
85
|
+
</div>
|
|
86
|
+
${r.it && r.it.description ? html`<p class="muted tw-desc">${r.it.description}</p>` : null}
|
|
87
|
+
<${WorkLines} lines=${r.lines} onOpen=${onOpen} />
|
|
88
|
+
</div>`)}
|
|
89
|
+
</section>`)}
|
|
90
|
+
</div>
|
|
91
|
+
</${Dialog}>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An instance's Details page: its cards, read once when the page opens (the page reads nothing recurring, #1374). */
|
|
95
|
+
export function InstanceBoardWork({ name, onOpen }) {
|
|
96
|
+
const [work, setWork] = useState(null);
|
|
97
|
+
const [error, setError] = useState("");
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
let live = true;
|
|
100
|
+
setWork(null); setError("");
|
|
101
|
+
boardApi.work(name).then((d) => { if (live) setWork((d.instances || [])[0] || { name }); }, (e) => { if (live) setError(errorText(e)); });
|
|
102
|
+
return () => { live = false; };
|
|
103
|
+
}, [name]);
|
|
104
|
+
return html`<section class="card d-board"><h3>${t("tasks.detailsBoard")}</h3>
|
|
105
|
+
${error ? html`<p class="note">${t("tasks.workFailed", error)}</p>` : !work ? html`<${Skeleton} lines=${2} />` : html`<${WorkLines} lines=${workLines(work)} onOpen=${onOpen} />`}
|
|
106
|
+
</section>`;
|
|
107
|
+
}
|
package/dist/ui/fleet-tasks.js
CHANGED
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
// need no drag.
|
|
9
9
|
import { html, useEffect, useMemo, useRef, useState } from "/assets/app-html.js";
|
|
10
10
|
import { t } from "/assets/app-i18n.js";
|
|
11
|
+
import { recordEdit } from "/assets/text-undo.js";
|
|
11
12
|
import { appStore, useStore } from "/assets/app-store.js";
|
|
12
13
|
import { Dialog } from "/assets/ui-dialog.js";
|
|
13
14
|
import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
|
|
14
15
|
import { Icon } from "/assets/ui-icons.js";
|
|
15
16
|
import { Menu } from "/assets/ui-menu.js";
|
|
16
17
|
import { toast } from "/assets/ui-toast.js";
|
|
17
|
-
import { AskHost, COLUMNS, NO_PROJECT, ProgressBar, StatusPill, UNASSIGNED, answerBlocked, boardApi, currentWork, dropNeighbours, errorText, followsSubs, moveCard, onPublicLink, projectKey, projectStats, readStored, recentlyDone, relTime, workersOf, wrapUpText, writeStored } from "./fleet-task-common.js";
|
|
18
|
+
import { AskHost, COLUMNS, NO_PROJECT, ProgressBar, StatusPill, UNASSIGNED, Who, answerBlocked, boardApi, currentWork, dropNeighbours, errorText, followsSubs, moveCard, onPublicLink, projectKey, projectStats, readStored, recentlyDone, relTime, workersOf, wrapUpText, writeStored } from "./fleet-task-common.js";
|
|
18
19
|
import { CardDialog } from "./fleet-task-card.js";
|
|
20
|
+
import { InstancesDrawer } from "./fleet-task-work.js";
|
|
19
21
|
|
|
20
22
|
const PROJECT_KEY = "agend_tasks_project";
|
|
21
23
|
const FILTER_KEY = "agend_tasks_assignee";
|
|
@@ -96,17 +98,22 @@ export function TaskBoardTab({ lease }) {
|
|
|
96
98
|
try { const u = new URL(globalThis.location.href); u.searchParams.delete("card"); globalThis.history.replaceState(globalThis.history.state, "", u); } catch { /* no address bar */ }
|
|
97
99
|
};
|
|
98
100
|
|
|
101
|
+
// #2246 step 3: who works on what — the drawer gives way to a card opened from it (one drawer at a time).
|
|
102
|
+
const showInstances = () => setDialog({ kind: "instances" });
|
|
103
|
+
|
|
99
104
|
if (!state) return html`<${Skeleton} lines=${5} />`;
|
|
100
105
|
if (state.error) return html`<${ErrorState} message=${t("tasks.loadFailed")} onRetry=${reload} />`;
|
|
101
106
|
const cards = Array.isArray(state.data.cards) ? state.data.cards : [];
|
|
102
107
|
const projects = Array.isArray(state.data.projects) ? state.data.projects : [];
|
|
108
|
+
// #2355 review: the names a project's lead may be, as the server accepts them (the fleet's instances).
|
|
109
|
+
const leads = Array.isArray(state.data.leads) ? state.data.leads : [];
|
|
103
110
|
// The server's numbers cover every card (finished ones are not all on the board); computed here only without them.
|
|
104
111
|
const stats = (key) => (state.data.stats && state.data.stats[String(key)]) || projectStats(key, cards);
|
|
105
112
|
const project = at === null ? null : at === NO_PROJECT ? NO_PROJECT : projects.find((p) => p.id === at) || null;
|
|
106
113
|
const body = project === null
|
|
107
|
-
? html`<${Home} cards=${cards} projects=${projects} stats=${stats} writable=${writable} onOpenProject=${setAt} onOpen=${openCard} onDialog=${setDialog} reload=${reload} />`
|
|
114
|
+
? html`<${Home} cards=${cards} projects=${projects} stats=${stats} writable=${writable} onOpenProject=${setAt} onOpen=${openCard} onDialog=${setDialog} onInstances=${showInstances} reload=${reload} />`
|
|
108
115
|
: html`<${ProjectBoard} project=${project} cards=${cards} stats=${stats} flashed=${flashed} allDone=${allDone} writable=${writable} onAllDone=${setAllDone} onBack=${() => setAt(null)}
|
|
109
|
-
onOpen=${openCard} onCreate=${() => setDialog({ kind: "main", project: project === NO_PROJECT ? null : project.id })} reload=${reload} />`;
|
|
116
|
+
onOpen=${openCard} onCreate=${() => setDialog({ kind: "main", project: project === NO_PROJECT ? null : project.id })} onInstances=${showInstances} reload=${reload} />`;
|
|
110
117
|
return html`<div class="tb">
|
|
111
118
|
${writable ? null : html`<p class="tb-readonly" role="note"><${Icon} name="eye" size=${16} />${t("tasks.readOnly")}</p>`}
|
|
112
119
|
${body}
|
|
@@ -114,7 +121,8 @@ export function TaskBoardTab({ lease }) {
|
|
|
114
121
|
onClose=${closeCard} onOpen=${openCard} reloadBoard=${reload} />` : null}
|
|
115
122
|
${dialog && dialog.kind === "main" ? html`<${CreateMainDialog} projects=${projects} project=${dialog.project} onClose=${() => setDialog(null)}
|
|
116
123
|
onCreated=${(id) => { if (!lease.current()) return; setDialog(null); reload(); openCard(id); }} />` : null}
|
|
117
|
-
${dialog && dialog.kind === "
|
|
124
|
+
${dialog && dialog.kind === "instances" ? html`<${InstancesDrawer} tick=${tick} onClose=${() => setDialog(null)} onOpen=${(id) => { setDialog(null); openCard(id); }} />` : null}
|
|
125
|
+
${dialog && dialog.kind === "project" ? html`<${ProjectDialog} project=${dialog.project} leads=${leads} onClose=${() => setDialog(null)}
|
|
118
126
|
onSaved=${(p) => { if (!lease.current()) return; setDialog(null); reload(); if (!dialog.project) setAt(p.id); }} />` : null}
|
|
119
127
|
<${AskHost} />
|
|
120
128
|
</div>`;
|
|
@@ -122,7 +130,12 @@ export function TaskBoardTab({ lease }) {
|
|
|
122
130
|
|
|
123
131
|
// ── Home: the projects, and what waits for the person ─────────
|
|
124
132
|
|
|
125
|
-
|
|
133
|
+
/** The board's Instance button (taskhall's): the fleet's instances and what each works on. */
|
|
134
|
+
function InstancesButton({ onClick }) {
|
|
135
|
+
return html`<button type="button" class="btn" title=${t("tasks.instancesTip")} onClick=${onClick}><${Icon} name="team" size=${16} />${t("tasks.instances")}</button>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function Home({ cards, projects, stats, writable, onOpenProject, onOpen, onDialog, onInstances, reload }) {
|
|
126
139
|
const [showArchived, setShowArchived] = useState(false);
|
|
127
140
|
const active = projects.filter((p) => !p.archived);
|
|
128
141
|
const archived = projects.filter((p) => p.archived);
|
|
@@ -131,6 +144,7 @@ function Home({ cards, projects, stats, writable, onOpenProject, onOpen, onDialo
|
|
|
131
144
|
<div class="list-head tb-head"><span>${t("tasks.projects")}</span>
|
|
132
145
|
<span class="tb-grow"></span>
|
|
133
146
|
<${SearchBox} onOpen=${onOpen} />
|
|
147
|
+
<${InstancesButton} onClick=${onInstances} />
|
|
134
148
|
${writable ? html`<button type="button" class="btn btn-primary" onClick=${() => onDialog({ kind: "project", project: null })}><${Icon} name="plus" size=${16} />${t("tasks.newProject")}</button>` : null}</div>
|
|
135
149
|
<${Waiting} cards=${cards} writable=${writable} onOpen=${onOpen} reload=${reload} />
|
|
136
150
|
${active.length || loose ? html`<div class="tb-projects">
|
|
@@ -161,6 +175,7 @@ function ProjectTile({ project, stats, onOpen, onDialog, reload }) {
|
|
|
161
175
|
${stats.waiting ? html`<span class="tb-flag st-review">${t("tasks.waiting", stats.waiting)}</span>` : null}
|
|
162
176
|
${stats.blocked ? html`<span class="tb-flag st-blocked">${t("tasks.blockedN", stats.blocked)}</span>` : null}
|
|
163
177
|
${stats.lastActivity ? html`<span class="muted">${t("tasks.lastActivity", relTime(stats.lastActivity))}</span>` : null}
|
|
178
|
+
${project && project.leadInstance ? html`<span class="muted tb-project-lead-chip">${t("tasks.projectLeadChip", project.leadInstance)}</span>` : null}
|
|
164
179
|
</span>
|
|
165
180
|
</button>
|
|
166
181
|
${project && onDialog ? html`<${Menu} cls="tb-project-menu" items=${[
|
|
@@ -233,7 +248,7 @@ function SearchBox({ onOpen }) {
|
|
|
233
248
|
if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => (i + 1) % results.length); }
|
|
234
249
|
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => (i - 1 + results.length) % results.length); }
|
|
235
250
|
else if (e.key === "Enter") { e.preventDefault(); pick(results[active].card.id); }
|
|
236
|
-
else if (e.key === "Escape") { e.stopPropagation(); setQ(""); }
|
|
251
|
+
else if (e.key === "Escape") { e.stopPropagation(); recordEdit(e.target); setQ(""); } // #2342: Ctrl+Z brings it back
|
|
237
252
|
}
|
|
238
253
|
return html`<div class="tb-search">
|
|
239
254
|
<${Icon} name="search" size=${16} cls="tb-search-icon" />
|
|
@@ -251,7 +266,7 @@ function SearchBox({ onOpen }) {
|
|
|
251
266
|
|
|
252
267
|
// ── A project's board ─────────────────────────────────────
|
|
253
268
|
|
|
254
|
-
function ProjectBoard({ project, cards, stats: statsOf, flashed, allDone, writable, onAllDone, onBack, onOpen, onCreate, reload }) {
|
|
269
|
+
function ProjectBoard({ project, cards, stats: statsOf, flashed, allDone, writable, onAllDone, onBack, onOpen, onCreate, onInstances, reload }) {
|
|
255
270
|
const { instances } = useStore(appStore);
|
|
256
271
|
const [assignee, setAssigneeRaw] = useState(() => readStored(FILTER_KEY, ""));
|
|
257
272
|
const [expanded, setExpanded] = useState({});
|
|
@@ -356,6 +371,7 @@ function ProjectBoard({ project, cards, stats: statsOf, flashed, allDone, writab
|
|
|
356
371
|
<option value=${UNASSIGNED}>${t("tasks.nobody")}</option>
|
|
357
372
|
${names.map((n) => html`<option key=${n} value=${n}>${n}</option>`)}
|
|
358
373
|
</select>
|
|
374
|
+
<${InstancesButton} onClick=${onInstances} />
|
|
359
375
|
${archived || !writable ? null : html`<button type="button" class="btn btn-primary" onClick=${onCreate}><${Icon} name="plus" size=${16} />${t("tasks.newMain")}</button>`}
|
|
360
376
|
</div>
|
|
361
377
|
${mains.length === 0 && !drag ? html`<${Empty} icon="tasks" title=${assignee ? t("tasks.emptyFiltered") : t("tasks.emptyBoard")} hint=${assignee || archived ? undefined : t("tasks.emptyBoardHint")} />`
|
|
@@ -442,9 +458,9 @@ function Tile({ card, dragging, flash, subs = [], expanded, writable, onToggleSu
|
|
|
442
458
|
<span class=${`tb-now-label st-${now.tone}`}>${t(now.label)}</span><span class="tb-code">${now.cards[0].id}</span>
|
|
443
459
|
<span class="tb-now-title">${now.cards[0].title}</span>${now.cards.length > 1 ? html`<span class="muted">${t("tasks.moreCards", now.cards.length - 1)}</span>` : null}</div>` : null}
|
|
444
460
|
<div class="tb-tile-foot">
|
|
445
|
-
${now ? html`<span class="tb-
|
|
461
|
+
${now ? html`<span class="tb-people">${workers.map((w) => html`<${Who} key=${w} name=${w} empty=${t("tasks.nobody")} title=${t("tasks.ownerTip", card.assignee || t("tasks.nobody"))} />`)}</span>`
|
|
446
462
|
: hasSubs && card.status === "review" ? html`<span class="tb-who tb-now-label st-review" title=${t("tasks.ownerTip", card.assignee || t("tasks.nobody"))}>${t("tasks.review")}</span>`
|
|
447
|
-
: html
|
|
463
|
+
: html`<${Who} name=${card.assignee} empty=${t("tasks.nobody")} />`}
|
|
448
464
|
<span class="tb-tile-stats">
|
|
449
465
|
${card.acceptance.length ? html`<span class=${accepted === card.acceptance.length ? "all" : ""} title=${t(card.kind === "main" ? "tasks.definition" : "tasks.acceptance")}><${Icon} name="check" size=${13} />${accepted}/${card.acceptance.length}</span>` : null}
|
|
450
466
|
${card.unmetDeps.length ? html`<span title=${t("tasks.waitsFor", card.unmetDeps.join(", "))}><${Icon} name="clock" size=${13} /></span>` : null}
|
|
@@ -495,14 +511,27 @@ function CreateMainDialog({ projects, project, onClose, onCreated }) {
|
|
|
495
511
|
</fieldset></form></${Dialog}>`;
|
|
496
512
|
}
|
|
497
513
|
|
|
498
|
-
|
|
499
|
-
|
|
514
|
+
/**
|
|
515
|
+
* #2355 review: what an edit of a project sends — its fields, and its lead only when it changed (an unchanged retired
|
|
516
|
+
* lead would be refused on an unrelated rename); "" is no lead.
|
|
517
|
+
*/
|
|
518
|
+
export function projectPatch(was, f) {
|
|
519
|
+
const { leadInstance, ...rest } = f;
|
|
520
|
+
return leadInstance !== (was || "") ? { ...rest, leadInstance: leadInstance || null } : rest;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function ProjectDialog({ project, leads = [], onClose, onSaved }) {
|
|
524
|
+
const was = (project && project.leadInstance) || "";
|
|
525
|
+
const [f, setF] = useState({ name: project ? project.name : "", description: project ? project.description : "", leadInstance: was });
|
|
526
|
+
// #2352: a lead no longer in the fleet stays listed (marked) until it is changed; #2355 review: only the server's names.
|
|
527
|
+
const leadGone = !!f.leadInstance && !leads.includes(f.leadInstance);
|
|
500
528
|
const [busy, setBusy] = useState(false);
|
|
501
529
|
async function submit() {
|
|
502
530
|
if (!f.name.trim()) return;
|
|
503
531
|
setBusy(true);
|
|
504
532
|
try {
|
|
505
|
-
const
|
|
533
|
+
const body = projectPatch(was, f);
|
|
534
|
+
const r = project ? await boardApi.updateProject(project.id, body) : await boardApi.createProject(f.name, f.description, f.leadInstance || null);
|
|
506
535
|
toast(t(project ? "tasks.projectSaved" : "tasks.projectCreated"));
|
|
507
536
|
onSaved(r.project);
|
|
508
537
|
} catch (err) { toast(errorText(err), false); setBusy(false); }
|
|
@@ -513,6 +542,11 @@ function ProjectDialog({ project, onClose, onSaved }) {
|
|
|
513
542
|
<form class="form" onSubmit=${(e) => { e.preventDefault(); submit(); }}><fieldset class="form" disabled=${busy}>
|
|
514
543
|
<label class="field"><span>${t("tasks.projectName")} *</span><input value=${f.name} maxlength="60" onInput=${(e) => { const v = e.target.value; setF((c) => ({ ...c, name: v })); }} required /></label>
|
|
515
544
|
<label class="field"><span>${t("tasks.projectDesc")}</span><input value=${f.description} maxlength="300" onInput=${(e) => { const v = e.target.value; setF((c) => ({ ...c, description: v })); }} /></label>
|
|
545
|
+
<label class="field"><span>${t("tasks.projectLead")}</span><select class="tb-project-lead" value=${f.leadInstance} onChange=${(e) => { const v = e.target.value; setF((c) => ({ ...c, leadInstance: v })); }}>
|
|
546
|
+
<option value="">${t("tasks.projectLeadNone")}</option>
|
|
547
|
+
${leadGone ? html`<option value=${f.leadInstance}>${t("tasks.projectLeadGone", f.leadInstance)}</option>` : null}
|
|
548
|
+
${leads.map((n) => html`<option key=${n} value=${n}>${n}</option>`)}</select>
|
|
549
|
+
<small class="note">${t("tasks.projectLeadHint")}</small></label>
|
|
516
550
|
</fieldset></form></${Dialog}>`;
|
|
517
551
|
}
|
|
518
552
|
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The native <dialog> opened with showModal(): the browser traps focus and makes the rest inert. No style attribute
|
|
7
7
|
// (#1300): classes only. Only a file URL the chat itself rendered (/ui/file/<32 hex>) is ever shown.
|
|
8
|
+
//
|
|
9
|
+
// #2333: zoom and pan (image-zoom.js). The toolbar has −, the level (it toggles fit ↔ 100%), + and Fit width; the keys
|
|
10
|
+
// + / − / 0 (fit) / 1 (100%) / W (fit width). Each image opens fitted; stepping to another fits it again.
|
|
8
11
|
import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
|
|
9
12
|
import { t } from "/assets/app-i18n.js";
|
|
10
13
|
import { createStore, useStore } from "/assets/app-store.js";
|
|
11
14
|
import { Icon } from "/assets/ui-icons.js";
|
|
15
|
+
import { createZoom, STEP } from "./image-zoom.js";
|
|
12
16
|
|
|
13
17
|
const FILE_URL = /^\/ui\/file\/[0-9a-f]{32}$/;
|
|
14
18
|
/** What is shown: { items: [{ src, name }], index, opener } or null. One per page; the chat that shows it owns it. */
|
|
@@ -54,6 +58,10 @@ function ImageLightbox({ items, index, opener, onClose }) {
|
|
|
54
58
|
const [at, setAt] = useState(index);
|
|
55
59
|
const ref = useRef(null);
|
|
56
60
|
const close = useRef(null);
|
|
61
|
+
const stage = useRef(null);
|
|
62
|
+
const img = useRef(null);
|
|
63
|
+
const zoom = useRef(null);
|
|
64
|
+
const [level, setLevel] = useState(null); // { scale, fit, mode } once the picture has loaded
|
|
57
65
|
const item = items[at] || items[0];
|
|
58
66
|
const many = items.length > 1;
|
|
59
67
|
const step = (d) => setAt((n) => (n + d + items.length) % items.length);
|
|
@@ -69,6 +77,19 @@ function ImageLightbox({ items, index, opener, onClose }) {
|
|
|
69
77
|
if (opener && typeof opener.focus === "function" && document.contains?.(opener)) opener.focus();
|
|
70
78
|
};
|
|
71
79
|
}, []);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!stage.current || !img.current) return undefined;
|
|
82
|
+
const z = createZoom({ stage: stage.current, img: img.current, onChange: setLevel });
|
|
83
|
+
zoom.current = z;
|
|
84
|
+
return () => { z.destroy(); zoom.current = null; };
|
|
85
|
+
}, []);
|
|
86
|
+
// Another image: hidden until it has loaded and been fitted (its load resets the zoom).
|
|
87
|
+
const first = useRef(true);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (first.current) { first.current = false; return; }
|
|
90
|
+
if (img.current) img.current.classList.remove("lb-ready");
|
|
91
|
+
setLevel(null);
|
|
92
|
+
}, [at]);
|
|
72
93
|
useEffect(() => {
|
|
73
94
|
const d = ref.current;
|
|
74
95
|
if (!d) return undefined;
|
|
@@ -80,6 +101,12 @@ function ImageLightbox({ items, index, opener, onClose }) {
|
|
|
80
101
|
// The dark area (the dialog itself, or the stage around the image) closes it; the image and the controls do not.
|
|
81
102
|
const onClick = (e) => { if (e.target === d || (e.target && e.target.classList && e.target.classList.contains("lb-stage"))) onClose(); };
|
|
82
103
|
const onKey = (e) => {
|
|
104
|
+
const z = zoom.current;
|
|
105
|
+
if (z && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
106
|
+
const act = { "+": () => z.zoomBy(STEP), "=": () => z.zoomBy(STEP), "-": () => z.zoomBy(1 / STEP), "_": () => z.zoomBy(1 / STEP),
|
|
107
|
+
"0": () => z.fit(), "1": () => z.actual(), w: () => z.fitWidth(), W: () => z.fitWidth() }[e.key];
|
|
108
|
+
if (act) { e.preventDefault(); act(); return; }
|
|
109
|
+
}
|
|
83
110
|
if (!many) return;
|
|
84
111
|
if (e.key === "ArrowRight") { e.preventDefault(); step(1); } else if (e.key === "ArrowLeft") { e.preventDefault(); step(-1); }
|
|
85
112
|
};
|
|
@@ -90,14 +117,25 @@ function ImageLightbox({ items, index, opener, onClose }) {
|
|
|
90
117
|
return () => { d.removeEventListener("cancel", onCancel); d.removeEventListener("close", onNativeClose); d.removeEventListener("click", onClick); d.removeEventListener("keydown", onKey); };
|
|
91
118
|
}, [onClose, many, items.length]);
|
|
92
119
|
const label = item.name ? t("chat.lbLabel", item.name) : t("chat.lbLabelNoName");
|
|
120
|
+
const pct = level ? Math.round(level.scale * 100) : null;
|
|
93
121
|
return html`<dialog data-trust="lightbox" ref=${ref} class="lb" role="dialog" aria-modal="true" aria-label=${label}>
|
|
94
122
|
<div class="lb-bar">
|
|
95
123
|
<span class="lb-name" title=${item.name}>${item.name}</span>
|
|
96
124
|
${many ? html`<span class="lb-count">${t("chat.lbCount", at + 1, items.length)}</span>` : null}
|
|
125
|
+
<span class="lb-zoom" role="group">
|
|
126
|
+
<button type="button" class="icon-btn lb-zoom-out" disabled=${!level} aria-label=${t("chat.lbZoomOut")} title=${t("chat.lbZoomOut")}
|
|
127
|
+
onClick=${() => zoom.current && zoom.current.zoomBy(1 / STEP)}><${Icon} name="minus" /></button>
|
|
128
|
+
<button type="button" class="btn btn-sm lb-level" disabled=${!level} aria-label=${t("chat.lbZoomLevel", pct ?? "")} title=${t("chat.lbZoomLevel", pct ?? "")}
|
|
129
|
+
onClick=${() => zoom.current && zoom.current.toggle()}>${pct === null ? "…" : `${pct}%`}</button>
|
|
130
|
+
<button type="button" class="icon-btn lb-zoom-in" disabled=${!level} aria-label=${t("chat.lbZoomIn")} title=${t("chat.lbZoomIn")}
|
|
131
|
+
onClick=${() => zoom.current && zoom.current.zoomBy(STEP)}><${Icon} name="plus" /></button>
|
|
132
|
+
<button type="button" class=${`icon-btn lb-fit-width${level && level.mode === "width" ? " on" : ""}`} disabled=${!level} aria-pressed=${level && level.mode === "width" ? "true" : "false"}
|
|
133
|
+
aria-label=${t("chat.lbFitWidth")} title=${t("chat.lbFitWidth")} onClick=${() => zoom.current && zoom.current.fitWidth()}><${Icon} name="fitwidth" /></button>
|
|
134
|
+
</span>
|
|
97
135
|
<a class="btn btn-sm lb-orig" href=${item.src} target="_blank" rel="noopener noreferrer"><${Icon} name="external" size=${14} /><span class="lbl">${t("chat.lbOpenOriginal")}</span></a>
|
|
98
136
|
<button type="button" class="icon-btn lb-close" ref=${close} aria-label=${t("chat.lbClose")} title=${t("chat.lbClose")} onClick=${onClose}><${Icon} name="close" /></button>
|
|
99
137
|
</div>
|
|
100
|
-
<div class="lb-stage"><img class="lb-img" src=${item.src} alt=${item.name} /></div>
|
|
138
|
+
<div class="lb-stage" ref=${stage}><img class="lb-img" ref=${img} src=${item.src} alt=${item.name} draggable=${false} /></div>
|
|
101
139
|
${many ? html`<button type="button" class="icon-btn lb-nav lb-prev" aria-label=${t("chat.lbPrev")} title=${t("chat.lbPrev")} onClick=${() => step(-1)}><${Icon} name="back" /></button>
|
|
102
140
|
<button type="button" class="icon-btn lb-nav lb-next" aria-label=${t("chat.lbNext")} title=${t("chat.lbNext")} onClick=${() => step(1)}><${Icon} name="forward" /></button>` : null}
|
|
103
141
|
</dialog>`;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// #2333: zoom and pan for the chat image lightbox. The math is pure (a view box, the image's natural size, a scale and
|
|
2
|
+
// an offset) and unit-tested; `createZoom` applies it to one <img> in one stage as a CSSOM transform — translate then
|
|
3
|
+
// scale from the top-left corner, so the browser never re-decodes the picture, and no style attribute is written
|
|
4
|
+
// (#1300: the CSSOM is not governed by the page's style-src).
|
|
5
|
+
//
|
|
6
|
+
// Gestures: wheel and ctrl/⌘+wheel (a trackpad pinch arrives as ctrl+wheel) zoom around the cursor; once the image is
|
|
7
|
+
// larger than the view a plain wheel scrolls it instead, so a tall sheet reads like a page. A mouse click on the image
|
|
8
|
+
// toggles fit ↔ 100% at that point; a drag pans. Touch: two fingers pinch (and pan), one finger pans, a double tap
|
|
9
|
+
// toggles fit ↔ 100%. A gesture that moved is never also a click (the lightbox closes on a click on the dark area).
|
|
10
|
+
|
|
11
|
+
/** The largest zoom: 8× the picture's own pixels. */
|
|
12
|
+
export const MAX_SCALE = 8;
|
|
13
|
+
/** One step of +/−, the keys or the toolbar. */
|
|
14
|
+
export const STEP = 1.25;
|
|
15
|
+
/** A pointer that moved less than this (CSS px) clicked or tapped; more, it dragged. */
|
|
16
|
+
export const SLOP = 4;
|
|
17
|
+
/** Two taps within this long and this close are a double tap. */
|
|
18
|
+
export const DOUBLE_TAP_MS = 300;
|
|
19
|
+
export const DOUBLE_TAP_PX = 24;
|
|
20
|
+
|
|
21
|
+
/** The whole picture in the view, never enlarged. view = { x, y, w, h } (stage coordinates); img = { w, h } natural. */
|
|
22
|
+
export function fitScale(view, img) {
|
|
23
|
+
return Math.min(1, view.w / img.w, view.h / img.h);
|
|
24
|
+
}
|
|
25
|
+
/** The picture's width across the view, never enlarged: a tall sheet then scrolls. */
|
|
26
|
+
export function fitWidthScale(view, img) {
|
|
27
|
+
return Math.min(1, view.w / img.w);
|
|
28
|
+
}
|
|
29
|
+
/** The scales a gesture may reach: down to fit (or 100%, for a picture smaller than the view), up to MAX_SCALE. */
|
|
30
|
+
export function scaleLimits(view, img) {
|
|
31
|
+
return { min: fitScale(view, img), max: Math.max(MAX_SCALE, fitScale(view, img)) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Keep the picture in view: an axis it does not fill is centred; an axis it overflows has no gap at either edge.
|
|
36
|
+
* z = { s, tx, ty } (tx, ty: where the picture's top-left corner is, in stage coordinates). Returns a new z.
|
|
37
|
+
*/
|
|
38
|
+
export function clampPan(z, view, img) {
|
|
39
|
+
const axis = (t, size, start, len) => size <= len ? start + (len - size) / 2 : Math.min(start, Math.max(start + len - size, t));
|
|
40
|
+
return { s: z.s, tx: axis(z.tx, img.w * z.s, view.x, view.w), ty: axis(z.ty, img.h * z.s, view.y, view.h) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Scale to `s` (within the limits) keeping the picture point under (px, py) where it is. */
|
|
44
|
+
export function zoomAt(z, s, px, py, view, img) {
|
|
45
|
+
const { min, max } = scaleLimits(view, img);
|
|
46
|
+
const to = Math.min(max, Math.max(min, s));
|
|
47
|
+
const k = to / z.s;
|
|
48
|
+
return clampPan({ s: to, tx: px - (px - z.tx) * k, ty: py - (py - z.ty) * k }, view, img);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Fit: the whole picture, centred. */
|
|
52
|
+
export function fitView(view, img) {
|
|
53
|
+
return clampPan({ s: fitScale(view, img), tx: 0, ty: 0 }, view, img);
|
|
54
|
+
}
|
|
55
|
+
/** Fit width: the picture across the view, from its top. */
|
|
56
|
+
export function fitWidthView(view, img) {
|
|
57
|
+
return clampPan({ s: fitWidthScale(view, img), tx: 0, ty: view.y }, view, img);
|
|
58
|
+
}
|
|
59
|
+
/** Whether the picture is larger than the view on either axis (then a plain wheel scrolls it, and a drag pans it). */
|
|
60
|
+
export function overflows(z, view, img) {
|
|
61
|
+
return img.w * z.s > view.w + 0.5 || img.h * z.s > view.h + 0.5;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Wheel distance in CSS px (a line is 16, a page the view's height). */
|
|
65
|
+
function wheelPx(e, view) {
|
|
66
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? view.h : 1;
|
|
67
|
+
return { dx: (e.deltaX || 0) * unit, dy: (e.deltaY || 0) * unit };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drive one picture in one stage. `onChange({ scale, fit, mode })` after every change (mode: "fit" | "width" | "free").
|
|
72
|
+
* Returns { reset, fit, fitWidth, actual, zoomBy, toggle, destroy }. Nothing happens until the picture has loaded.
|
|
73
|
+
*/
|
|
74
|
+
export function createZoom({ stage, img, onChange }) {
|
|
75
|
+
let nat = null; // { w, h } once loaded
|
|
76
|
+
let z = { s: 1, tx: 0, ty: 0 };
|
|
77
|
+
let mode = "fit";
|
|
78
|
+
const pointers = new Map(); // pointerId → { x, y }
|
|
79
|
+
let gesture = null; // { kind: "drag" | "pinch", ... }
|
|
80
|
+
let moved = false; // the gesture now ending moved: its click is not a click
|
|
81
|
+
let lastTap = null; // { t, x, y } of the last touch tap
|
|
82
|
+
let lastType = "mouse"; // the pointer type of the last press: a touch tap's click never toggles (double tap does);
|
|
83
|
+
// a mouse or a pen click does, and neither runs the double-tap logic
|
|
84
|
+
|
|
85
|
+
const view = () => {
|
|
86
|
+
const r = stage.getBoundingClientRect();
|
|
87
|
+
const cs = typeof getComputedStyle === "function" ? getComputedStyle(stage) : null;
|
|
88
|
+
const px = (k) => (cs ? parseFloat(cs[k]) || 0 : 0);
|
|
89
|
+
const x = px("paddingLeft"), y = px("paddingTop");
|
|
90
|
+
return { x, y, w: Math.max(1, r.width - x - px("paddingRight")), h: Math.max(1, r.height - y - px("paddingBottom")) };
|
|
91
|
+
};
|
|
92
|
+
const at = (e) => { const r = stage.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
|
|
93
|
+
const apply = () => {
|
|
94
|
+
img.style.transform = `translate(${z.tx}px, ${z.ty}px) scale(${z.s})`;
|
|
95
|
+
stage.classList.toggle("lb-zoomed", !!nat && overflows(z, view(), nat));
|
|
96
|
+
if (onChange) onChange({ scale: z.s, fit: nat ? fitScale(view(), nat) : 1, mode });
|
|
97
|
+
};
|
|
98
|
+
const set = (next, m = "free") => { if (!nat) return; z = next; mode = m; apply(); };
|
|
99
|
+
|
|
100
|
+
const api = {
|
|
101
|
+
/** A new picture (or the first): read its size and fit it. */
|
|
102
|
+
reset() {
|
|
103
|
+
nat = img.naturalWidth > 0 && img.naturalHeight > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null;
|
|
104
|
+
img.classList.toggle("lb-ready", !!nat);
|
|
105
|
+
img.classList.remove("lb-err");
|
|
106
|
+
if (nat) set(fitView(view(), nat), "fit");
|
|
107
|
+
},
|
|
108
|
+
fit() { if (nat) set(fitView(view(), nat), "fit"); },
|
|
109
|
+
fitWidth() { if (nat) set(fitWidthView(view(), nat), "width"); },
|
|
110
|
+
/** 100% at a point (default: the middle of the view). */
|
|
111
|
+
actual(px, py) {
|
|
112
|
+
if (!nat) return;
|
|
113
|
+
const v = view();
|
|
114
|
+
set(zoomAt(z, 1, px ?? v.x + v.w / 2, py ?? v.y + v.h / 2, v, nat));
|
|
115
|
+
},
|
|
116
|
+
zoomBy(factor, px, py) {
|
|
117
|
+
if (!nat) return;
|
|
118
|
+
const v = view();
|
|
119
|
+
set(zoomAt(z, z.s * factor, px ?? v.x + v.w / 2, py ?? v.y + v.h / 2, v, nat));
|
|
120
|
+
},
|
|
121
|
+
/** Fit ↔ 100%: anything but fit goes back to fit. */
|
|
122
|
+
toggle(px, py) {
|
|
123
|
+
if (!nat) return;
|
|
124
|
+
if (mode === "fit" && Math.abs(z.s - 1) > 1e-6) api.actual(px, py); else api.fit();
|
|
125
|
+
},
|
|
126
|
+
destroy() {
|
|
127
|
+
stage.removeEventListener("wheel", onWheel);
|
|
128
|
+
stage.removeEventListener("pointerdown", onDown);
|
|
129
|
+
stage.removeEventListener("pointermove", onMove);
|
|
130
|
+
stage.removeEventListener("pointerup", onUp);
|
|
131
|
+
stage.removeEventListener("pointercancel", onUp);
|
|
132
|
+
stage.removeEventListener("click", onClick, true);
|
|
133
|
+
img.removeEventListener("load", api.reset);
|
|
134
|
+
img.removeEventListener("error", onError);
|
|
135
|
+
if (typeof window !== "undefined") window.removeEventListener("resize", onResize);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function onWheel(e) {
|
|
140
|
+
if (!nat) return;
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
const v = view();
|
|
143
|
+
const { dx, dy } = wheelPx(e, v);
|
|
144
|
+
if (e.ctrlKey || e.metaKey || !overflows(z, v, nat)) {
|
|
145
|
+
const p = at(e);
|
|
146
|
+
// A pinch on a trackpad sends small ctrl+wheel steps; a wheel notch is ~100 px.
|
|
147
|
+
set(zoomAt(z, z.s * Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.002)), p.x, p.y, v, nat));
|
|
148
|
+
} else {
|
|
149
|
+
// Shift+wheel on a mouse scrolls sideways.
|
|
150
|
+
const sx = e.shiftKey && !dx ? dy : dx, sy = e.shiftKey && !dx ? 0 : dy;
|
|
151
|
+
set(clampPan({ s: z.s, tx: z.tx - sx, ty: z.ty - sy }, v, nat), mode === "width" ? "width" : "free");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function onDown(e) {
|
|
155
|
+
if (!nat || (e.pointerType === "mouse" && e.button !== 0)) return;
|
|
156
|
+
lastType = e.pointerType || "mouse";
|
|
157
|
+
pointers.set(e.pointerId, at(e));
|
|
158
|
+
// No pointer capture yet: it would retarget the click to the stage, and a click on the picture is not one on the
|
|
159
|
+
// dark area. Captured only once the pointer moves (capture()).
|
|
160
|
+
if (pointers.size === 1) { moved = false; gesture = { kind: "drag", from: at(e), z0: z }; }
|
|
161
|
+
else if (pointers.size === 2) {
|
|
162
|
+
const [a, b] = [...pointers.values()];
|
|
163
|
+
gesture = { kind: "pinch", d0: Math.hypot(b.x - a.x, b.y - a.y) || 1, m0: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, z0: z };
|
|
164
|
+
moved = true; // two fingers: never a tap
|
|
165
|
+
for (const id of pointers.keys()) capture(id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function onMove(e) {
|
|
169
|
+
if (!gesture || !pointers.has(e.pointerId)) return;
|
|
170
|
+
pointers.set(e.pointerId, at(e));
|
|
171
|
+
const v = view();
|
|
172
|
+
if (gesture.kind === "pinch" && pointers.size >= 2) {
|
|
173
|
+
const [a, b] = [...pointers.values()];
|
|
174
|
+
const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
175
|
+
const { z0, m0, d0 } = gesture;
|
|
176
|
+
const s = z0.s * (Math.hypot(b.x - a.x, b.y - a.y) / d0);
|
|
177
|
+
// The picture point under the fingers' first midpoint follows the midpoint (zoom and pan in one).
|
|
178
|
+
const zoomed = zoomAt(z0, s, m0.x, m0.y, v, nat);
|
|
179
|
+
set(clampPan({ s: zoomed.s, tx: zoomed.tx + (m.x - m0.x), ty: zoomed.ty + (m.y - m0.y) }, v, nat));
|
|
180
|
+
} else if (gesture.kind === "drag") {
|
|
181
|
+
const p = pointers.get(e.pointerId);
|
|
182
|
+
const dx = p.x - gesture.from.x, dy = p.y - gesture.from.y;
|
|
183
|
+
if (!moved && Math.hypot(dx, dy) < SLOP) return;
|
|
184
|
+
if (!moved) capture(e.pointerId);
|
|
185
|
+
moved = true;
|
|
186
|
+
if (overflows(gesture.z0, v, nat)) set(clampPan({ s: gesture.z0.s, tx: gesture.z0.tx + dx, ty: gesture.z0.ty + dy }, v, nat), mode === "width" ? "width" : "free");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function onUp(e) {
|
|
190
|
+
if (!pointers.has(e.pointerId)) return;
|
|
191
|
+
const p = pointers.get(e.pointerId);
|
|
192
|
+
pointers.delete(e.pointerId);
|
|
193
|
+
if (pointers.size === 1 && gesture && gesture.kind === "pinch") {
|
|
194
|
+
// One finger left after a pinch: it pans on from here.
|
|
195
|
+
const [rest] = [...pointers.values()];
|
|
196
|
+
gesture = { kind: "drag", from: rest, z0: z };
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (pointers.size > 0) return;
|
|
200
|
+
const tapped = !moved && gesture && gesture.kind === "drag";
|
|
201
|
+
gesture = null;
|
|
202
|
+
// Only a finger double-taps: a mouse or a pen clicks (onClick toggles), so a pen tap must not also count here.
|
|
203
|
+
if (!tapped || e.pointerType !== "touch") return;
|
|
204
|
+
// A touch tap: a second one soon and near is a double tap — fit ↔ 100% there.
|
|
205
|
+
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
206
|
+
if (lastTap && now - lastTap.t < DOUBLE_TAP_MS && Math.hypot(p.x - lastTap.x, p.y - lastTap.y) < DOUBLE_TAP_PX) {
|
|
207
|
+
lastTap = null;
|
|
208
|
+
moved = true; // its click neither closes nor toggles again
|
|
209
|
+
api.toggle(p.x, p.y);
|
|
210
|
+
} else lastTap = { t: now, x: p.x, y: p.y };
|
|
211
|
+
}
|
|
212
|
+
/** A drag or pinch keeps its pointers even off the stage. */
|
|
213
|
+
function capture(id) {
|
|
214
|
+
if (stage.setPointerCapture) { try { stage.setPointerCapture(id); } catch { /* already gone */ } }
|
|
215
|
+
}
|
|
216
|
+
// Capture phase: before the lightbox's own click (which closes on the dark area) can see it. A click that ended a
|
|
217
|
+
// drag or a double tap is swallowed; a mouse click on the picture toggles fit ↔ 100% there. A touch tap's click on
|
|
218
|
+
// the picture does nothing (a single tap waits for a double tap); on the dark area it still closes.
|
|
219
|
+
function onClick(e) {
|
|
220
|
+
if (moved) { moved = false; e.stopPropagation(); e.preventDefault(); return; }
|
|
221
|
+
if (e.target === img && nat && lastType !== "touch") {
|
|
222
|
+
const p = at(e);
|
|
223
|
+
e.stopPropagation();
|
|
224
|
+
api.toggle(p.x, p.y);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// A picture that does not load is shown as the browser shows a broken one, where the old lightbox put it.
|
|
228
|
+
const onError = () => { nat = null; img.classList.add("lb-err"); img.classList.remove("lb-ready"); if (onChange) onChange(null); };
|
|
229
|
+
const onResize = () => { if (!nat) return; if (mode === "fit") api.fit(); else if (mode === "width") api.fitWidth(); else set(clampPan(z, view(), nat)); };
|
|
230
|
+
|
|
231
|
+
stage.addEventListener("wheel", onWheel, { passive: false });
|
|
232
|
+
stage.addEventListener("pointerdown", onDown);
|
|
233
|
+
stage.addEventListener("pointermove", onMove);
|
|
234
|
+
stage.addEventListener("pointerup", onUp);
|
|
235
|
+
stage.addEventListener("pointercancel", onUp);
|
|
236
|
+
stage.addEventListener("click", onClick, true);
|
|
237
|
+
img.addEventListener("load", api.reset);
|
|
238
|
+
img.addEventListener("error", onError);
|
|
239
|
+
if (typeof window !== "undefined") window.addEventListener("resize", onResize);
|
|
240
|
+
if (img.complete) api.reset();
|
|
241
|
+
return api;
|
|
242
|
+
}
|