Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: rewire a notification flow by drag or keyboard

The flow editor can now change a flow's shape on the canvas, not only
through the inspector's fields:

- drag a box onto another to move it and its branch there; a drop the
  save would refuse shows the target dashed red and gives the reason;
- a palette of node kinds adds a node below the picked box on click, or
  below any box it is dragged onto; it offers what the controller lists;
- arrow keys walk the tree, M picks a box up and Enter drops it under
  the focused one, Alt+Up/Down reorder a node among its siblings, Delete
  removes a branch; focus follows the edit;
- each box with siblings says when it runs, and the inspector can move
  it earlier or later, since the router runs children in edge order;
- request_reviewer is a second leaf kind beside todo, with a reviewer
  field;
- leaving with unsaved edits asks first, also for in-app links and the
  back button (a router hook runs navigation past a page's guard).
This commit is contained in:
iris 2026-10-24 14:49:48 +02:00 • committed by mara
commit 68e289fff5
16 changed files with 1072 additions and 133 deletions

View file

@ -0,0 +1,88 @@
// Stops a page with unsaved edits from losing them without asking.
//
// Closing or reloading the tab gets the browser's own prompt. A move inside the
// app goes past the nav guard, and the browser back button is caught with a
// spare history entry: while there are edits the page pushes a copy of its own
// URL, so back lands on the same page and the prompt can be shown.
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { navigate } from "wouter-preact/use-browser-location";
import { setNavGuard } from "../../shell/navGuard.js";
// `to` is where the person was headed, or null for the back button.
interface Pending {
to: string | null;
}
export function useLeaveGuard(dirty: boolean): {
pending: boolean;
leave: () => void;
stay: () => void;
// Lets the page's own navigation through, such as after deleting the flow.
release: () => void;
} {
const [pending, setPending] = useState<Pending | null>(null);
const spare = useRef(false);
const released = useRef(false);
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
useEffect(() => {
if (!dirty) return;
setNavGuard((to) => {
if (released.current) return true;
setPending({ to });
return false;
});
return () => setNavGuard(null);
}, [dirty]);
useEffect(() => {
const onPop = () => {
if (!spare.current) return;
if (dirty && !released.current) {
// Back landed on the page again; the spare entry is used up.
spare.current = false;
setPending({ to: null });
} else {
// Nothing left to protect: go on to where back was meant to go.
spare.current = false;
history.back();
}
};
window.addEventListener("popstate", onPop);
if (dirty && !spare.current) {
history.pushState(history.state, "", location.href);
spare.current = true;
}
return () => window.removeEventListener("popstate", onPop);
}, [dirty]);
const stay = useCallback(() => {
if (pending?.to === null && !spare.current) {
history.pushState(history.state, "", location.href);
spare.current = true;
}
setPending(null);
}, [pending]);
const leave = useCallback(() => {
released.current = true;
setNavGuard(null);
const to = pending?.to;
setPending(null);
if (to === null) history.back();
else if (to !== undefined) navigate(to);
}, [pending]);
const release = useCallback(() => {
released.current = true;
setNavGuard(null);
}, []);
return { pending: pending !== null, leave, stay, release };
}