diff --git a/.changeset/smooth-readonly-feedback.md b/.changeset/smooth-readonly-feedback.md new file mode 100644 index 00000000000..d4f989ce38b --- /dev/null +++ b/.changeset/smooth-readonly-feedback.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Explain blocked edits with a brief read-only notice and an optional action to create an editable copy. diff --git a/apps/petrinaut-website/src/examples/full-example-page.tsx b/apps/petrinaut-website/src/examples/full-example-page.tsx index 5fdfd0cee6e..b5066840511 100644 --- a/apps/petrinaut-website/src/examples/full-example-page.tsx +++ b/apps/petrinaut-website/src/examples/full-example-page.tsx @@ -50,6 +50,17 @@ export const FullExamplePage = ({ const handle = getReadonlyExampleHandle(example); const navigation = useSharedSearchNavigation(search, onSearchChange); + const forkLocalCopy = () => { + try { + onFork(); + setForkError(null); + } catch { + setForkError( + "Your browser couldn't save a copy. Free up browser storage and try again.", + ); + } + }; + useEffect(() => { const previousTitle = document.title; document.title = `${example.catalog.title} ยท Petrinaut`; @@ -97,22 +108,10 @@ export const FullExamplePage = ({ navigation={navigation} presentationProfile="review" readonly + readOnlyAction={{ label: "Make a local copy", onClick: forkLocalCopy }} slots={{ topBarEnd: ( - ), diff --git a/libs/@hashintel/petrinaut/README.md b/libs/@hashintel/petrinaut/README.md index f93866836f9..34362e91f75 100644 --- a/libs/@hashintel/petrinaut/README.md +++ b/libs/@hashintel/petrinaut/README.md @@ -49,6 +49,12 @@ passes `readonly` as well, which is what disables the fields themselves: /> ``` +Blocked node drags and editing shortcuts show a brief read-only notice. To offer +an action such as saving an editable copy, pass +`readOnlyAction={{ label: "Make a copy", onClick: createEditableCopy }}`. The host +owns the callback and persistence. The action appears for host-enforced +read-only documents; mode and simulation restrictions show the relevant next step. + ## Commands and the palette Petrinaut registers its user-invocable actions (undo, tools, search, panel diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 026665907c8..f94ce245fef 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -9,6 +9,12 @@ move it between browsers. To edit a published example, choose **Make a local copy** in its top bar. The copy opens at its own local URL, and the published example stays unchanged. +Trying to drag a node or use an editing shortcut on a read-only net shows a brief +notice. Clicking to inspect a node, panning, and zooming remain available. On a +published example, the notice also offers **Make a local copy**. In Simulate or +Actual mode, it points you back to Edit; when a simulation holds the net, it asks +you to reset the simulation first. + ## Editor layout The editor is organized around a central canvas where you build your net: diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts index 45fffbe7793..bb02d22f0f0 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts @@ -3,6 +3,7 @@ import { use } from "react"; import { PetrinautInstanceContext } from "../instance-context"; import { ActiveNetContext } from "../state/active-net-context"; import { useIsReadOnly } from "../state/use-is-read-only"; +import { useReadOnlyFeedback } from "./use-read-only-feedback"; import type { PetrinautCommands } from "@hashintel/petrinaut-core"; @@ -23,12 +24,14 @@ export function usePetrinautCommands(): PetrinautCommands { ); } const isReadOnly = useIsReadOnly(); + const notifyReadOnly = useReadOnlyFeedback(); const { activeSubnetId } = use(ActiveNetContext); const { commands } = instance; return { applyClipboardPaste(input) { if (isReadOnly) { + notifyReadOnly(); return { newItemIds: [] }; } return commands.applyClipboardPaste({ @@ -38,6 +41,7 @@ export function usePetrinautCommands(): PetrinautCommands { }, async applyAutoLayout() { if (isReadOnly) { + notifyReadOnly(); return { commitCount: 0 }; } return commands.applyAutoLayout({ targetSubnetId: activeSubnetId }); diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx index 57b5cdc77dc..9638afbb929 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -3,7 +3,7 @@ */ import { act, renderHook } from "@testing-library/react"; import { type ReactNode } from "react"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { DEFAULT_PETRINAUT_EXTENSIONS, @@ -14,6 +14,7 @@ import { } from "@hashintel/petrinaut-core"; import { PetrinautInstanceContext } from "../instance-context"; +import { NotificationsContext } from "../notifications/context"; import { SimulationContext, type SimulationState } from "../simulation/context"; import { EditorContext, @@ -378,3 +379,68 @@ describe("usePetrinautMutations", () => { }); }); }); + +test.each([ + [{ readonly: true }, "This document is read-only."], + [{ globalMode: "simulate" }, "Switch to Edit to change this net."], + [{ simulationState: "Paused" }, "Reset the simulation to edit this net."], +] satisfies [WrapperOptions, string][])( + "explains blocked mutations for %j", + (options, message) => { + const { Wrapper, instance } = createWrapper(options); + const addNotification = vi.fn(() => "notice"); + const { result } = renderHook(usePetrinautMutations, { + wrapper: ({ children }) => ( + + {} }} + > + {children} + + + ), + }); + act(() => + result.current.addPlace({ + id: "blocked", + name: "Blocked", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ); + expect(instance.definition.get().places).toHaveLength(0); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ message, tone: "neutral" }), + ); + }, +); + +test("allowed scenario mutations in Simulate stay quiet", () => { + const { Wrapper, instance } = createWrapper({ globalMode: "simulate" }); + const addNotification = vi.fn(() => "notice"); + const { result } = renderHook(usePetrinautMutations, { + wrapper: ({ children }) => ( + + {} }} + > + {children} + + + ), + }); + act(() => + result.current.addScenario({ + id: "scenario", + name: "Scenario", + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ); + expect(instance.definition.get().scenarios).toHaveLength(1); + expect(addNotification).not.toHaveBeenCalled(); +}); diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts index 52a8829ceff..e4273023059 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -5,6 +5,7 @@ import { ActiveNetContext } from "../state/active-net-context"; import { SDCPNContext } from "../state/sdcpn-context"; import { simulateModeAllowedMutationNames } from "../state/simulate-mode-allowed-mutation-names"; import { useIsReadOnly } from "../state/use-is-read-only"; +import { useReadOnlyFeedback } from "./use-read-only-feedback"; import type { PetrinautMutations } from "@hashintel/petrinaut-core"; @@ -38,6 +39,7 @@ export function usePetrinautMutations(): PetrinautMutations { const { readonly } = use(SDCPNContext); const { activeSubnetId } = use(ActiveNetContext); const isReadOnly = useIsReadOnly(); + const notifyReadOnly = useReadOnlyFeedback(); const { mutations } = instance; const withReadonlyGuard = ( @@ -50,6 +52,7 @@ export function usePetrinautMutations(): PetrinautMutations { ) => void; const wrapped = ((input: PetrinautMutationInput) => { if (allowedInSimulate ? readonly : isReadOnly) { + notifyReadOnly(); return; } const nextInput = diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-read-only-feedback.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-read-only-feedback.ts new file mode 100644 index 00000000000..6c4f3fe8cd8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-read-only-feedback.ts @@ -0,0 +1,30 @@ +import { use } from "react"; + +import { NotificationsContext } from "../notifications/context"; +import { ReadOnlyActionContext } from "../state/read-only-action-context"; +import { SDCPNContext } from "../state/sdcpn-context"; +import { useReadOnlyReason } from "../state/use-read-only-reason"; + +export const useReadOnlyFeedback = () => { + const reason = useReadOnlyReason(); + const action = use(ReadOnlyActionContext); + const { petriNetId } = use(SDCPNContext); + const { addNotification } = use(NotificationsContext); + + return () => { + if (reason === null) return; + const message = + reason.kind === "host-readonly" + ? "This document is read-only." + : reason.kind === "simulation-active" + ? "Reset the simulation to edit this net." + : "Switch to Edit to change this net."; + addNotification({ + id: `read-only:${petriNetId}:${reason.kind}`, + message, + tone: "neutral", + durationMs: 4500, + action: reason.kind === "host-readonly" ? action : undefined, + }); + }; +}; diff --git a/libs/@hashintel/petrinaut/src/react/notifications/context.ts b/libs/@hashintel/petrinaut/src/react/notifications/context.ts index 5f508982ae4..e5f24cc1ca8 100644 --- a/libs/@hashintel/petrinaut/src/react/notifications/context.ts +++ b/libs/@hashintel/petrinaut/src/react/notifications/context.ts @@ -3,6 +3,8 @@ import { createContext } from "react"; export type NotificationTone = "error" | "neutral" | "success"; export type AddNotificationInput = { + id?: string; + action?: { label: string; onClick: () => void }; detail?: string; message: string; tone?: NotificationTone; diff --git a/libs/@hashintel/petrinaut/src/react/notifications/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/notifications/provider.test.tsx index 877109371e4..2a7d3d883d3 100644 --- a/libs/@hashintel/petrinaut/src/react/notifications/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/notifications/provider.test.tsx @@ -134,3 +134,42 @@ test("shows a way out only where a notification needs one", async () => { ); expect(failure?.querySelector("[aria-label='Copy details']")).toBeTruthy(); }); + +test("deduplicates a notice and offers its action without stealing focus", async () => { + const onClick = vi.fn(); + const Trigger = () => { + const { addNotification } = use(NotificationsContext); + return ( + + ); + }; + render( + + + , + ); + const trigger = screen.getByRole("button", { name: "Try editing" }); + trigger.focus(); + fireEvent.click(trigger); + const action = await screen.findByRole("button", { + name: "Make a local copy", + }); + expect(screen.getAllByText("This document is read-only.")).toHaveLength(1); + expect(document.activeElement).toBe(trigger); + fireEvent.click(action); + expect(onClick).toHaveBeenCalledOnce(); +}); diff --git a/libs/@hashintel/petrinaut/src/react/notifications/provider.tsx b/libs/@hashintel/petrinaut/src/react/notifications/provider.tsx index 945584fe104..5bbe1ad5c3b 100644 --- a/libs/@hashintel/petrinaut/src/react/notifications/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/notifications/provider.tsx @@ -23,12 +23,14 @@ export const NotificationsProvider = ({ }; const addNotification = ({ + action, + id: suppliedId, detail, durationMs, message, tone = "success", }: AddNotificationInput) => { - const id = `notification-${nextNotificationId}`; + const id = suppliedId ?? `notification-${nextNotificationId}`; nextNotificationId += 1; const effectiveDurationMs = tone === "error" @@ -36,12 +38,14 @@ export const NotificationsProvider = ({ : (durationMs ?? DEFAULT_NOTIFICATION_DURATION_MS); queueMicrotask(() => { + if (notificationsToaster.isVisible(id)) return; notificationsToaster.create({ + ...(action ? { action } : {}), description: detail, duration: effectiveDurationMs, id, title: message, - type: tone, + type: tone === "neutral" ? "info" : tone, }); }); diff --git a/libs/@hashintel/petrinaut/src/react/notifications/toaster.tsx b/libs/@hashintel/petrinaut/src/react/notifications/toaster.tsx index e5426c89a1f..278c4e03c95 100644 --- a/libs/@hashintel/petrinaut/src/react/notifications/toaster.tsx +++ b/libs/@hashintel/petrinaut/src/react/notifications/toaster.tsx @@ -118,6 +118,13 @@ export const NotificationsToaster = () => ( )} + {toast.action && ( + + + + )} {dismissible && (
{detail && ( diff --git a/libs/@hashintel/petrinaut/src/react/state/read-only-action-context.ts b/libs/@hashintel/petrinaut/src/react/state/read-only-action-context.ts new file mode 100644 index 00000000000..6b38eae9416 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/read-only-action-context.ts @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +export type ReadOnlyAction = { label: string; onClick: () => void }; + +export const ReadOnlyActionContext = createContext( + undefined, +); diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index ad39b1ccfd7..bcb5be4928a 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -17,6 +17,10 @@ import { } from "@hashintel/petrinaut-core"; import { PetrinautProvider } from "../react/petrinaut-provider"; +import { + ReadOnlyActionContext, + type ReadOnlyAction, +} from "../react/state/read-only-action-context"; import { Stack } from "./components/stack"; import { MonacoProvider } from "./monaco/provider"; import { EditorView } from "./views/Editor/editor-view"; @@ -108,6 +112,8 @@ export type PetrinautProps = { title?: string; setTitle?: (title: string) => void; readonly?: boolean; + /** An action offered when an edit is blocked by a read-only document. */ + readOnlyAction?: ReadOnlyAction; /** * Controls visibility of net-management UI in the editor's top bar and * burger menu. @@ -174,6 +180,7 @@ export const Petrinaut: FunctionComponent = ({ title = "Untitled", setTitle = noop, readonly = false, + readOnlyAction, hideNetManagementControls, existingNets = [], createNewNet = noop, @@ -204,31 +211,33 @@ export const Petrinaut: FunctionComponent = ({ }; return ( - - - - - - - - - - - + + + + + + + + + + + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.test.tsx new file mode 100644 index 00000000000..0419724c3d1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.test.tsx @@ -0,0 +1,80 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { use, type ReactNode } from "react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { EditorContext } from "../../../../../react/state/editor-context"; +import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; + +const actions = vi.hoisted(() => ({ + notify: vi.fn(), + remove: vi.fn(), + paste: vi.fn(), + mode: vi.fn(), +})); +vi.mock("../../../../../react", () => ({ + usePetrinautMutations: () => ({ deleteItemsByIds: actions.remove }), + usePetrinautCommands: () => ({ applyClipboardPaste: actions.paste }), +})); +vi.mock("../../../../../react/hooks/use-read-only-feedback", () => ({ + useReadOnlyFeedback: () => actions.notify, +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const Shortcuts = () => { + useKeyboardShortcuts("edit", actions.mode, () => {}); + return ; +}; +const Selection = ({ children }: { children: ReactNode }) => { + const defaults = use(EditorContext); + return ( + + {children} + + ); +}; + +test.each([ + { key: "Delete" }, + { key: "Backspace" }, + { key: "n" }, + { key: "t" }, + { key: "v", metaKey: true }, + { key: "z", metaKey: true }, +])( + "explains the blocked $key shortcut without changing the document", + (key) => { + render( + + + , + ); + fireEvent.keyDown(document.body, key); + expect(actions.notify).toHaveBeenCalledOnce(); + expect(actions.remove).not.toHaveBeenCalled(); + expect(actions.paste).not.toHaveBeenCalled(); + expect(actions.mode).not.toHaveBeenCalled(); + }, +); + +test("typing and selection shortcuts stay quiet", () => { + render( + + + , + ); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "n" }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Backspace" }); + fireEvent.keyDown(document.body, { key: "v" }); + expect(actions.notify).not.toHaveBeenCalled(); + expect(actions.mode).toHaveBeenCalledWith("cursor"); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index d03c1ea4aff..020f7372345 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -4,6 +4,7 @@ import { usePetrinautMutations, usePetrinautCommands, } from "../../../../../react"; +import { useReadOnlyFeedback } from "../../../../../react/hooks/use-read-only-feedback"; import { ActiveNetContext } from "../../../../../react/state/active-net-context"; import { EditorContext } from "../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; @@ -43,6 +44,7 @@ export function useKeyboardShortcuts( const { deleteItemsByIds } = usePetrinautMutations(); const { applyClipboardPaste } = usePetrinautCommands(); const isReadonly = useIsReadOnly(); + const notifyReadOnly = useReadOnlyFeedback(); const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { const target = event.target as HTMLElement; @@ -56,16 +58,19 @@ export function useKeyboardShortcuts( // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. if ( - undoRedo && !isInputFocused && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" ) { event.preventDefault(); + if (isReadonly) { + notifyReadOnly(); + return; + } if (event.shiftKey) { - undoRedo.redo(); + undoRedo?.redo(); } else { - undoRedo.undo(); + undoRedo?.undo(); } return; } @@ -109,8 +114,12 @@ export function useKeyboardShortcuts( return; } - if (key === "v" && !isReadonly) { + if (key === "v") { event.preventDefault(); + if (isReadonly) { + notifyReadOnly(); + return; + } void pasteFromClipboard(applyClipboardPaste).then((newItemIds) => { if (newItemIds && newItemIds.length > 0) { setSelection( @@ -151,12 +160,12 @@ export function useKeyboardShortcuts( } // Delete selected items with Backspace or Delete - if ( - (event.key === "Delete" || event.key === "Backspace") && - !isReadonly && - hasSelection - ) { + if ((event.key === "Delete" || event.key === "Backspace") && hasSelection) { event.preventDefault(); + if (isReadonly) { + notifyReadOnly(); + return; + } deleteItemsByIds({ items: Array.from(selection.values()) }); clearSelection(); return; @@ -186,13 +195,19 @@ export function useKeyboardShortcuts( onEditionModeChange("cursor"); break; case "n": - if (mode === "edit") { + if (isReadonly) { + event.preventDefault(); + notifyReadOnly(); + } else if (mode === "edit") { event.preventDefault(); onEditionModeChange("add-place"); } break; case "t": - if (mode === "edit") { + if (isReadonly) { + event.preventDefault(); + notifyReadOnly(); + } else if (mode === "edit") { event.preventDefault(); onEditionModeChange("add-transition"); } diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas.tsx index 00c158e2f29..18fc40df9ed 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas.tsx @@ -48,6 +48,7 @@ import { useApplyNodeChanges } from "./react-flow-canvas/use-apply-node-changes" import { useMonacoKeyboardIsolation } from "./react-flow-canvas/use-monaco-keyboard-isolation"; import { useReactFlowController } from "./react-flow-canvas/use-react-flow-controller"; import { useReactFlowElements } from "./react-flow-canvas/use-react-flow-elements"; +import { useReadOnlyNodeDrag } from "./react-flow-canvas/use-read-only-node-drag"; import type { CanvasNodeKind } from "../../canvas-scene"; import type { Connection, IsValidConnection } from "@xyflow/react"; @@ -119,6 +120,7 @@ const ReactFlowCanvasInner: CanvasRenderer = ({ const interactions = useCanvasInteractions(scene); const flowStore = useStoreApi(); + const readOnlyDragHandlers = useReadOnlyNodeDrag(interactions.readonly); const controller = useReactFlowController(); const { nodes, edges } = useReactFlowElements(scene); const applyChanges = useApplyNodeChanges(interactions); @@ -221,6 +223,7 @@ const ReactFlowCanvasInner: CanvasRenderer = ({ return (
vi.fn()); +vi.mock("../../../../../../react/hooks/use-read-only-feedback", () => ({ + useReadOnlyFeedback: () => notify, +})); +afterEach(() => { + cleanup(); + notify.mockClear(); +}); + +const Canvas = ({ readonly = true }: { readonly?: boolean }) => { + const handlers = useReadOnlyNodeDrag(readonly); + return ( +
+
+ Place + +
+
Canvas
+
+ ); +}; + +const pointer = (target: Element, type: string, x: number, button = 0) => + fireEvent( + target, + new MouseEvent(type, { bubbles: true, clientX: x, clientY: 20, button }), + ); + +test("notifies once per attempted node drag after the movement threshold", () => { + render(); + const node = screen.getByTestId("node"); + pointer(node, "pointerdown", 20); + pointer(node, "pointermove", 24); + expect(notify).not.toHaveBeenCalled(); + pointer(node, "pointermove", 30); + pointer(node, "pointermove", 50); + expect(notify).toHaveBeenCalledOnce(); +}); + +test("keeps selection, pane panning, middle-button drags and controls quiet", () => { + render(); + const node = screen.getByTestId("node"); + pointer(node, "pointerdown", 20); + pointer(node, "pointerup", 20); + pointer(node, "pointermove", 80); + for (const target of [ + screen.getByTestId("pane"), + screen.getByRole("button"), + ]) { + pointer(target, "pointerdown", 20); + pointer(target, "pointermove", 80); + } + pointer(node, "pointerdown", 20, 1); + pointer(node, "pointermove", 80, 1); + expect(notify).not.toHaveBeenCalled(); +}); + +test("editable drags and canceled gestures stay quiet", () => { + const view = render(); + const node = screen.getByTestId("node"); + pointer(node, "pointerdown", 20); + pointer(node, "pointermove", 80); + view.rerender(); + pointer(node, "pointerdown", 20); + pointer(node, "pointercancel", 20); + pointer(node, "pointermove", 80); + expect(notify).not.toHaveBeenCalled(); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-read-only-node-drag.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-read-only-node-drag.ts new file mode 100644 index 00000000000..b9b7f2d347d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-read-only-node-drag.ts @@ -0,0 +1,53 @@ +import { useRef, type PointerEvent } from "react"; + +import { useReadOnlyFeedback } from "../../../../../../react/hooks/use-read-only-feedback"; + +export const useReadOnlyNodeDrag = (readonly: boolean) => { + const start = useRef<{ pointerId: number; x: number; y: number } | null>( + null, + ); + const notifyReadOnly = useReadOnlyFeedback(); + const clear = () => { + start.current = null; + }; + + return { + onPointerDownCapture: (event: PointerEvent) => { + clear(); + const target = event.target; + if ( + !readonly || + event.button !== 0 || + event.isPrimary === false || + !(target instanceof Element) + ) + return; + if ( + !target.closest(".react-flow__node, .react-flow__nodesselection-rect") + ) + return; + if ( + target.closest( + "button, input, textarea, select, a, [contenteditable=true], .nodrag", + ) + ) + return; + start.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + }, + onPointerMoveCapture: (event: PointerEvent) => { + const origin = start.current; + if (!readonly || !origin || origin.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) < 6) + return; + clear(); + notifyReadOnly(); + }, + onPointerUpCapture: clear, + onPointerCancelCapture: clear, + onPointerLeave: clear, + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/use-canvas-interactions.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/use-canvas-interactions.ts index 2a2d62e6b5f..25f0066b238 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/use-canvas-interactions.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/use-canvas-interactions.ts @@ -13,6 +13,7 @@ import { } from "@hashintel/petrinaut-core"; import { usePetrinautMutations } from "../../../react/hooks/use-petrinaut-mutations"; +import { useReadOnlyFeedback } from "../../../react/hooks/use-read-only-feedback"; import { EditorContext } from "../../../react/state/editor-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../react/state/use-is-read-only"; @@ -137,6 +138,7 @@ export const useCanvasInteractions = ( } = use(EditorContext); const { snapToGrid } = use(UserSettingsContext); const readonly = useIsReadOnly(); + const notifyReadOnly = useReadOnlyFeedback(); const isAddMode = editionMode === "add-place" || @@ -234,6 +236,7 @@ export const useCanvasInteractions = ( const createNodeAt = (kind: DraggedNodeKind, rawPosition: CanvasPoint) => { if (readonly) { + notifyReadOnly(); return; } const id = `${kind}__${generateUuid()}`;