diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index e1d53688154..1a970488ae2 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -15,7 +15,8 @@ "@sentry/react": "10.22.0", "immer": "10.1.3", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "react-icons": "5.5.0" }, "devDependencies": { "@types/react": "19.2.7", diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index 0a0f0fdd54a..9b911f91207 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -1,8 +1,9 @@ import type { MinimalNetMetadata, SDCPN } from "@hashintel/petrinaut"; import { convertOldFormatToSDCPN, Petrinaut } from "@hashintel/petrinaut"; import { produce } from "immer"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; import { isOldFormatInLocalStorage, type SDCPNInLocalStorage, @@ -10,67 +11,63 @@ import { } from "./app/use-local-storage-sdcpns"; import { useUndoRedo } from "./app/use-undo-redo"; +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}; + export const DevApp = () => { + const sentryFeedbackAction = useSentryFeedbackAction(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); const [currentNetId, setCurrentNetId] = useState(null); - const currentNet = useMemo(() => { - if (!currentNetId) { - return null; - } - return storedSDCPNs[currentNetId] ?? null; - }, [currentNetId, storedSDCPNs]); - - const existingNets: MinimalNetMetadata[] = useMemo(() => { - return Object.values(storedSDCPNs) - .filter( - (net): net is SDCPNInLocalStorage => !isOldFormatInLocalStorage(net), - ) - .map((net) => ({ - netId: net.id, - title: net.title, - })); - }, [storedSDCPNs]); - - const createNewNet = useCallback( - (params: { - petriNetDefinition: SDCPN; - title: string; - }) => { - const newNet: SDCPNInLocalStorage = { - id: `net-${Date.now()}`, - title: params.title, - sdcpn: params.petriNetDefinition, - lastUpdated: new Date().toISOString(), - }; - - setStoredSDCPNs((prev) => ({ ...prev, [newNet.id]: newNet })); - setCurrentNetId(newNet.id); - }, - [setStoredSDCPNs], - ); + const currentNet = currentNetId ? (storedSDCPNs[currentNetId] ?? null) : null; + + const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) + .filter( + (net): net is SDCPNInLocalStorage => !isOldFormatInLocalStorage(net), + ) + .map((net) => ({ + netId: net.id, + title: net.title, + })); + + const createNewNet = (params: { + petriNetDefinition: SDCPN; + title: string; + }) => { + const newNet: SDCPNInLocalStorage = { + id: `net-${Date.now()}`, + title: params.title, + sdcpn: params.petriNetDefinition, + lastUpdated: new Date().toISOString(), + }; + + setStoredSDCPNs((prev) => ({ ...prev, [newNet.id]: newNet })); + setCurrentNetId(newNet.id); + }; - const loadPetriNet = useCallback((petriNetId: string) => { + const loadPetriNet = (petriNetId: string) => { setCurrentNetId(petriNetId); - }, []); + }; - const setTitle = useCallback( - (title: string) => { - if (!currentNetId) { - return; - } + const setTitle = (title: string) => { + if (!currentNetId) { + return; + } - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - if (draft[currentNetId] && "title" in draft[currentNetId]) { - draft[currentNetId].title = title; - } - }), - ); - }, - [currentNetId, setStoredSDCPNs], - ); + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + if (draft[currentNetId] && "title" in draft[currentNetId]) { + draft[currentNetId].title = title; + } + }), + ); + }; const setSDCPNDirectly = (sdcpn: SDCPN) => { if (!currentNetId) { @@ -85,14 +82,6 @@ export const DevApp = () => { ); }; - const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], - }; - const { pushState, undo: undoHistory, @@ -106,7 +95,7 @@ export const DevApp = () => { } = useUndoRedo( currentNet && !isOldFormatInLocalStorage(currentNet) ? currentNet.sdcpn - : emptySDCPN, + : EMPTY_SDCPN, ); const mutatePetriNetDefinition = ( @@ -253,6 +242,7 @@ export const DevApp = () => { setTitle={setTitle} title={currentNet.title} undoRedo={undoRedo} + viewportActions={[sentryFeedbackAction]} /> ); diff --git a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx new file mode 100644 index 00000000000..1da6f4d18d9 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx @@ -0,0 +1,36 @@ +import type { ViewportAction } from "@hashintel/petrinaut"; +import * as Sentry from "@sentry/react"; +import { MdBugReport } from "react-icons/md"; + +const feedbackButtonStyle: React.CSSProperties = { + backgroundColor: "#8b5cf6dd", + borderColor: "#7c3aed", + color: "#fff", +}; + +export function useSentryFeedbackAction(): ViewportAction { + // Wouldn't be optimized by React Compiler otherwise + "use memo"; + + return { + key: "sentry-feedback", + icon: , + label: "Give feedback", + tooltip: "Give feedback", + style: feedbackButtonStyle, + ref: (node) => { + const feedback = Sentry.getFeedback(); + + if (!node || !feedback) { + return; + } + + // Attach feedback to the button and return the unsubscribe function + return feedback.attachTo(node, { + formTitle: "Give feedback", + messagePlaceholder: "Report a bug or suggest an improvement", + submitButtonLabel: "Submit feedback", + }); + }, + }; +} diff --git a/apps/petrinaut-website/src/sentry/instrument.ts b/apps/petrinaut-website/src/sentry/instrument.ts index 7cf1372eea5..6a24681a2db 100644 --- a/apps/petrinaut-website/src/sentry/instrument.ts +++ b/apps/petrinaut-website/src/sentry/instrument.ts @@ -8,8 +8,8 @@ Sentry.init({ Sentry.browserApiErrorsIntegration(), Sentry.browserTracingIntegration(), Sentry.feedbackIntegration({ + autoInject: false, colorScheme: "system", - triggerLabel: "", formTitle: "Give feedback", messagePlaceholder: "Report a bug or suggest an improvement", submitButtonLabel: "Submit feedback", diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index d3ea8d69b4b..2ec4665f108 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -25,6 +25,7 @@ import { type UndoRedoContextValue, } from "./state/undo-redo-context"; import { UserSettingsProvider } from "./state/user-settings-provider"; +import type { ViewportAction } from "./types/viewport-action"; import { EditorView } from "./views/Editor/editor-view"; export { isSDCPNEqual } from "./lib/deep-equal"; @@ -41,6 +42,7 @@ export type { }; export type { UndoRedoContextValue as UndoRedoProps } from "./state/undo-redo-context"; +export type { ViewportAction } from "./types/viewport-action"; export type PetrinautProps = { /** @@ -103,11 +105,17 @@ export type PetrinautProps = { * undo/redo buttons in the top bar and register keyboard shortcuts. */ undoRedo?: UndoRedoContextValue; + /** + * Optional additional action buttons to render in the viewport controls panel, + * after the built-in buttons. + */ + viewportActions?: ViewportAction[]; }; export const Petrinaut: FunctionComponent = ({ hideNetManagementControls, undoRedo, + viewportActions, ...rest }) => { return ( @@ -122,6 +130,7 @@ export const Petrinaut: FunctionComponent = ({ diff --git a/libs/@hashintel/petrinaut/src/types/viewport-action.ts b/libs/@hashintel/petrinaut/src/types/viewport-action.ts new file mode 100644 index 00000000000..dfea8c8f662 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/types/viewport-action.ts @@ -0,0 +1,18 @@ +export type ViewportAction = { + /** Unique key for React rendering. */ + key: string; + /** Icon element to render inside the button. */ + icon: React.ReactNode; + /** Accessible label for the button. */ + label: string; + /** Tooltip text shown on hover. */ + tooltip: string; + /** Click handler. */ + onClick?: () => void; + /** Inline styles applied to the button element. */ + style?: React.CSSProperties; + /** CSS class name applied to the button element. */ + className?: string; + /** Ref callback to access the underlying button DOM element. */ + ref?: React.Ref; +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 0fe4dc934c0..3efa6428d6c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -14,6 +14,7 @@ import { EditorContext } from "../../state/editor-context"; import { PortalContainerContext } from "../../state/portal-container-context"; import { SDCPNContext } from "../../state/sdcpn-context"; import { useSelectionCleanup } from "../../state/use-selection-cleanup"; +import type { ViewportAction } from "../../types/viewport-action"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { BottomBar } from "./components/BottomBar/bottom-bar"; import { TopBar } from "./components/TopBar/top-bar"; @@ -57,8 +58,10 @@ const portalContainerStyle = css({ */ export const EditorView = ({ hideNetManagementControls, + viewportActions, }: { hideNetManagementControls: boolean; + viewportActions?: ViewportAction[]; }) => { // Get data from sdcpn-store const { @@ -259,7 +262,7 @@ export const EditorView = ({ {/* SDCPN Visualization */} - + {/* Bottom Panel - Diagnostics, Simulation Settings */} diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx index e079354dcd8..08ff6f7c045 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx @@ -12,6 +12,7 @@ import { import { IconButton } from "../../../components/icon-button"; import { PANEL_MARGIN } from "../../../constants/ui"; import { EditorContext } from "../../../state/editor-context"; +import type { ViewportAction } from "../../../types/viewport-action"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; const BASE_OFFSET = 12; @@ -35,7 +36,9 @@ const animatingStyle = cva({ }, }); -export const ViewportControls: React.FC = () => { +export const ViewportControls: React.FC<{ + viewportActions?: ViewportAction[]; +}> = ({ viewportActions }) => { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const { zoomIn, zoomOut } = useReactFlow(); const { @@ -110,6 +113,21 @@ export const ViewportControls: React.FC = () => { open={isSettingsOpen} onOpenChange={(details) => setIsSettingsOpen(details.open)} /> + {viewportActions?.map((action) => ( + + {action.icon} + + ))} ); }; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx index 6088c7e43d0..5f0a0a9aea1 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx @@ -14,6 +14,7 @@ import { EditorContext } from "../../state/editor-context"; import { SDCPNContext } from "../../state/sdcpn-context"; import { useIsReadOnly } from "../../state/use-is-read-only"; import { UserSettingsContext } from "../../state/user-settings-context"; +import type { ViewportAction } from "../../types/viewport-action"; import { Arc } from "./components/arc"; import { ClassicPlaceNode } from "./components/classic-place-node"; import { ClassicTransitionNode } from "./components/classic-transition-node"; @@ -54,7 +55,9 @@ const canvasContainerStyle = css({ * SDCPNView is responsible for rendering the SDCPN using ReactFlow. * It reads from SDCPNContext and EditorContext, and handles all ReactFlow interactions. */ -export const SDCPNView: React.FC = () => { +export const SDCPNView: React.FC<{ + viewportActions?: ViewportAction[]; +}> = ({ viewportActions }) => { const canvasContainer = useRef(null); const [reactFlowInstance, setReactFlowInstance] = useState(null); @@ -335,7 +338,7 @@ export const SDCPNView: React.FC = () => { > - + ); diff --git a/yarn.lock b/yarn.lock index 49fd0ac25d6..261c4f5a514 100644 --- a/yarn.lock +++ b/yarn.lock @@ -792,6 +792,7 @@ __metadata: oxlint: "npm:1.55.0" react: "npm:19.2.3" react-dom: "npm:19.2.3" + react-icons: "npm:5.5.0" vite: "npm:8.0.0-beta.18" languageName: unknown linkType: soft