diff --git a/cypress/integration/embedded-mode.spec.ts b/cypress/integration/embedded-mode.spec.ts index e7dfc202..d1015bcd 100644 --- a/cypress/integration/embedded-mode.spec.ts +++ b/cypress/integration/embedded-mode.spec.ts @@ -34,18 +34,22 @@ describe('Embedded mode', () => { }); }); describe('default (mode:viz)1', () => { - before(() => { + it('panels should be hidden', () => { cy.interceptGraphQL({ getSourceFile: sourceFileFixture, }); cy.visitEmbedWithNextPageProps({ sourceFile: sourceFileFixture, }); - }); - it('panels should be hidden', () => { - cy.getPanelsView().should('be.hidden'); + cy.getPanelsView().should('not.exist'); }); it('canvas header should be hidden', () => { + cy.interceptGraphQL({ + getSourceFile: sourceFileFixture, + }); + cy.visitEmbedWithNextPageProps({ + sourceFile: sourceFileFixture, + }); cy.getCanvasHeader().should('not.exist'); }); }); diff --git a/src/ActionLabelBeforeElement.tsx b/src/ActionLabelBeforeElement.tsx new file mode 100644 index 00000000..ecd1b84e --- /dev/null +++ b/src/ActionLabelBeforeElement.tsx @@ -0,0 +1,36 @@ +import { Box } from '@chakra-ui/react'; + +export function ActionLabelBeforeElement({ + children, + kind, + title, +}: { + children: React.ReactNode; + kind: string; + title?: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/ActionViz.scss b/src/ActionViz.scss deleted file mode 100644 index 293fac83..00000000 --- a/src/ActionViz.scss +++ /dev/null @@ -1,28 +0,0 @@ -[data-viz='action'] { - color: var(--viz-color-fg); - display: flex; - flex-direction: row; - gap: 1ch; - align-items: baseline; - justify-content: flex-start; - - &[data-viz-action] { - &:before { - content: attr(data-viz-action) ' / '; - font-size: var(--viz-font-size-sm); - text-transform: uppercase; - font-weight: bold; - opacity: 0.5; - display: inline-block; - white-space: nowrap; - } - } -} - -[data-viz='action-type'] { - font-size: var(--chakra-fontSizes-xs); - max-width: 15ch; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} diff --git a/src/ActionViz.tsx b/src/ActionViz.tsx index bc61bd69..be648d60 100644 --- a/src/ActionViz.tsx +++ b/src/ActionViz.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef, useState } from 'react'; import { ActionObject, ActionTypes, @@ -12,6 +11,8 @@ import { SpecialTargets, StopAction, } from 'xstate'; +import { ActionLabelBeforeElement } from './ActionLabelBeforeElement'; +import { SpecificActionLabel } from './SpecificActionLabel'; import { isDelayedTransitionAction, isStringifiedFunction } from './utils'; type AnyFunction = (...args: any[]) => any; @@ -41,33 +42,13 @@ export function getActionLabel(action: ActionObject): string | null { return action.type; } -export const ActionType: React.FC<{ title?: string }> = ({ - children, - title, -}) => { - const ref = useRef(null); - const [resolvedTitle, setTitle] = useState(title || ''); - - useEffect(() => { - if (ref.current && !title) { - setTitle(ref.current.textContent!); - } - }, [title]); - - return ( -
- {children} -
- ); -}; - export const RaiseActionLabel: React.FC<{ action: PotentiallyStructurallyCloned>; }> = ({ action }) => { return ( - + raise {action.event} - + ); }; @@ -76,9 +57,9 @@ export const SendActionLabel: React.FC<{ }> = ({ action }) => { if (!action.event) { return ( - + send unknown - + ); } @@ -103,9 +84,9 @@ export const SendActionLabel: React.FC<{ ); return ( - + {actionLabel} {actionTo} - + ); }; @@ -113,9 +94,9 @@ export const LogActionLabel: React.FC<{ action: PotentiallyStructurallyCloned>; }> = ({ action }) => { return ( - + log {action.label} - + ); }; @@ -123,9 +104,9 @@ export const CancelActionLabel: React.FC<{ action: PotentiallyStructurallyCloned; }> = ({ action }) => { return ( - + cancel {action.sendId} - + ); }; @@ -133,14 +114,14 @@ export const StopActionLabel: React.FC<{ action: PotentiallyStructurallyCloned>; }> = ({ action }) => { return ( - + stop{' '} {typeof action.activity === 'object' && 'id' in action.activity ? ( action.activity.id ) : ( expr )} - + ); }; @@ -148,14 +129,14 @@ export const AssignActionLabel: React.FC<{ action: PotentiallyStructurallyCloned>; }> = ({ action }) => { return ( - + assign{' '} {typeof action.assignment === 'object' ? ( Object.keys(action.assignment).join(', ') ) : ( {action.assignment?.name || 'expr'} )} - + ); }; @@ -163,10 +144,10 @@ export const ChooseActionLabel: React.FC<{ action: PotentiallyStructurallyCloned>; }> = () => { return ( - + choose {/* TODO: recursively add actions/guards */} - + ); }; @@ -180,9 +161,9 @@ export const CustomActionLabel: React.FC<{ } return ( - + {label === 'anonymous' ? anonymous : {label}} - + ); }; @@ -218,8 +199,8 @@ export const ActionViz: React.FC<{ }[action.type] ?? ; return ( -
+ {actionType} -
+ ); }; diff --git a/src/App.tsx b/src/App.tsx index cfffc708..b3d2ad88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,49 @@ -import { Box, ChakraProvider } from '@chakra-ui/react'; -import React, { useEffect, useMemo } from 'react'; +import { ExternalLinkIcon, QuestionOutlineIcon } from '@chakra-ui/icons'; +import { + Box, + Button, + IconButton, + Link, + Menu, + MenuButton, + MenuItem, + MenuList, + Portal, +} from '@chakra-ui/react'; import { useActor, useInterpret, useSelector } from '@xstate/react'; -import { useAuth } from './authContext'; +import router, { useRouter } from 'next/router'; +import { useEffect, useMemo } from 'react'; +import xstatePkgJson from 'xstate/package.json'; import { AppHead } from './AppHead'; +import { useAuth } from './authContext'; import { CanvasProvider } from './CanvasContext'; -import { EmbedProvider } from './embedContext'; +import { CanvasHeader } from './CanvasHeader'; +import { canvasMachine, canvasModel } from './canvasMachine'; import { CanvasView } from './CanvasView'; +import { CommonAppProviders } from './CommonAppProviders'; +import { EmbedProvider, useEmbed } from './embedContext'; import { isOnClientSide } from './isOnClientSide'; +import { Login } from './Login'; import { MachineNameChooserModal } from './MachineNameChooserModal'; import { PaletteProvider } from './PaletteContext'; import { paletteMachine } from './paletteMachine'; import { PanelsView } from './PanelsView'; -import { SimulationProvider } from './SimulationContext'; -import { simulationMachine } from './simulationMachine'; -import { getSourceActor, useSourceRegistryData } from './sourceMachine'; -import { theme } from './theme'; -import { EditorThemeProvider } from './themeContext'; -import { EmbedContext, EmbedMode } from './types'; -import { useInterpretCanvas } from './useInterpretCanvas'; -import router, { useRouter } from 'next/router'; -import { parseEmbedQuery, withoutEmbedQueryParams } from './utils'; import { registryLinks } from './registryLinks'; +import { RootContainer } from './RootContainer'; +import { useSimulation } from './SimulationContext'; +import { getSourceActor, useSourceRegistryData } from './sourceMachine'; +import { ActorsTab } from './tabs/ActorsTab'; +import { CodeTab } from './tabs/CodeTab'; +import { EventsTab } from './tabs/EventsTab'; +import { SettingsTab } from './tabs/SettingsTab'; +import { StateTab } from './tabs/StateTab'; +import { EmbedMode } from './types'; +import { + calculatePanelIndexByPanelName, + parseEmbedQuery, + withoutEmbedQueryParams, +} from './utils'; +import { WelcomeArea } from './WelcomeArea'; const defaultHeadProps = { title: 'XState Visualizer', @@ -62,32 +85,61 @@ const useReceiveMessage = ( }, []); }; -const getGridArea = (embed?: EmbedContext) => { - if (embed?.isEmbedded && embed.mode === EmbedMode.Viz) { - return 'canvas'; - } - - if (embed?.isEmbedded && embed.mode === EmbedMode.Panels) { - return 'panels'; - } - - return 'canvas panels'; -}; - -function App({ isEmbedded = false }: { isEmbedded?: boolean }) { - const { query, asPath } = useRouter(); - const embed = useMemo( - () => ({ - ...parseEmbedQuery(query), - isEmbedded, - originalUrl: withoutEmbedQueryParams(query), - }), - [query, asPath], +function ControlsAdditionalMenu() { + return ( + + + } + /> + + + + Report an issue + + + {`XState version ${xstatePkgJson.version}`} + + + Privacy Policy + + + + ); +} - const paletteService = useInterpret(paletteMachine); - // don't use `devTools: true` here as it would freeze your browser - const simService = useInterpret(simulationMachine); +function WebApp() { + const embed = useEmbed(); + const pannable = !embed?.isEmbedded || embed.pan; + const zoomable = !embed?.isEmbedded || embed.zoom; + + const simService = useSimulation(); const machine = useSelector(simService, (state) => { return state.context.currentSessionId ? state.context.serviceDataMap[state.context.currentSessionId!]?.machine @@ -96,6 +148,17 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) { const sourceService = useSelector(useAuth(), getSourceActor); const [sourceState, sendToSourceService] = useActor(sourceService!); + const sourceID = sourceState.context.sourceID; + const canShowWelcomeMessage = sourceState.hasTag('canShowWelcomeMessage'); + + const canvasService = useInterpret(canvasMachine, { + context: { + ...canvasModel.initialContext, + sourceID, + pannable, + zoomable, + }, + }); useReceiveMessage({ // used to receive messages from the iframe in embed preview @@ -111,48 +174,118 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) { }); }, [machine?.id, sendToSourceService]); - // TODO: Subject to refactor into embedActor + useEffect(() => { + canvasService.send({ + type: 'SOURCE_CHANGED', + id: sourceID, + }); + }, [sourceID, canvasService]); - const sourceID = sourceState!.context.sourceID; + const shouldRenderCanvas = + !embed?.isEmbedded || embed.mode !== EmbedMode.Panels; + const shouldRenderPanels = !embed?.isEmbedded || embed.mode !== EmbedMode.Viz; - const canvasService = useInterpretCanvas({ - sourceID, - embed, - }); + return ( + <> + + + + + ) + } + Empty={canShowWelcomeMessage && } + ControlsAdditionalMenu={ + !embed?.isEmbedded && + } + /> + + ) + } + panels={ + shouldRenderPanels && ( + { + const tabs = [CodeTab, StateTab, EventsTab, ActorsTab]; + if (!embed?.isEmbedded) { + tabs.push(SettingsTab); + } + return tabs; + })()} + tabListRightButtons={ + !embed?.isEmbedded ? ( + + ) : embed.showOriginalLink && embed.originalUrl ? ( + + ) : null + } + resizable={!embed?.isEmbedded || embed.mode === EmbedMode.Full} + /> + ) + } + /> + + + ); +} - // This is because we're doing loads of things on client side anyway - if (!isOnClientSide()) return ; +function App({ isEmbedded = false }: { isEmbedded?: boolean }) { + const { query, asPath } = useRouter(); + const embed = useMemo( + () => ({ + ...parseEmbedQuery(query), + isEmbedded, + originalUrl: withoutEmbedQueryParams(query), + }), + [query, asPath], + ); + + const paletteService = useInterpret(paletteMachine); return ( <> - - - + {/* This is because we're doing loads of things on client side anyway */} + {isOnClientSide() && ( + + - - - {!(embed?.isEmbedded && embed.mode === EmbedMode.Panels) && ( - - - - )} - - - - + - - - + + + )} ); } diff --git a/src/AppHead.tsx b/src/AppHead.tsx index 4dc16427..f5fed940 100644 --- a/src/AppHead.tsx +++ b/src/AppHead.tsx @@ -19,10 +19,9 @@ export interface AppHeadProps { ogTitle: string; description: string; ogImageUrl: string | null; - importElk?: boolean; } -export const AppHead = ({ importElk = true, ...props }: AppHeadProps) => { +export const AppHead = (props: AppHeadProps) => { return ( @@ -30,9 +29,7 @@ export const AppHead = ({ importElk = true, ...props }: AppHeadProps) => { {props.title} - {importElk && ( - - )} + diff --git a/src/ArrowMarker.tsx b/src/ArrowMarker.tsx index 52615603..c534f935 100644 --- a/src/ArrowMarker.tsx +++ b/src/ArrowMarker.tsx @@ -12,7 +12,7 @@ export const ArrowMarker: React.FC<{ id: string }> = ({ id }) => { markerUnits="strokeWidth" orient="auto" > - + ); }; diff --git a/src/CanvasContainer.tsx b/src/CanvasContainer.tsx index a816a514..a1302098 100644 --- a/src/CanvasContainer.tsx +++ b/src/CanvasContainer.tsx @@ -1,269 +1,19 @@ -import React, { CSSProperties, useEffect, useRef } from 'react'; -import { canvasModel, ZoomFactor } from './canvasMachine'; +import { useSelector } from '@xstate/react'; +import React, { CSSProperties, useEffect } from 'react'; import { useCanvas } from './CanvasContext'; -import { useMachine } from '@xstate/react'; -import { actions } from 'xstate'; -import { createModel } from 'xstate/lib/model'; -import { Point } from './pathUtils'; +import { canvasModel, ZoomFactor } from './canvasMachine'; import { isAcceptingArrowKey, - isAcceptingSpaceNatively, isTextInputLikeElement, isWithPlatformMetaKey, } from './utils'; -import { useEmbed } from './embedContext'; -import { - dragSessionModel, - dragSessionTracker, - DragSession, - PointDelta, -} from './dragSessionTracker'; -import { AnyState } from './types'; - -const dragModel = createModel( - { - ref: null as React.MutableRefObject | null, - }, - { - events: { - LOCK: () => ({}), - RELEASE: () => ({}), - ENABLE_PANNING: (sessionSeed: DragSession | null = null) => ({ - sessionSeed, - }), - DISABLE_PANNING: () => ({}), - ENABLE_PAN_MODE: () => ({}), - DISABLE_PAN_MODE: () => ({}), - DRAG_SESSION_STARTED: ({ point }: { point: Point }) => ({ - point, - }), - DRAG_SESSION_STOPPED: () => ({}), - POINTER_MOVED_BY: ({ delta }: { delta: PointDelta }) => ({ - delta, - }), - WHEEL_PRESSED: (data: DragSession) => ({ data }), - WHEEL_RELEASED: () => ({}), - }, - }, -); - -const dragMachine = dragModel.createMachine( - { - preserveActionOrder: true, - initial: 'checking_if_disabled', - states: { - checking_if_disabled: { - always: [ - { - target: 'permanently_disabled', - cond: 'isPanDisabled', - }, - 'enabled', - ], - }, - permanently_disabled: {}, - enabled: { - type: 'parallel', - states: { - mode: { - initial: 'lockable', - states: { - lockable: { - initial: 'released', - states: { - released: { - invoke: [ - { - src: 'invokeDetectLock', - }, - { - src: 'wheelPressListener', - }, - ], - on: { - LOCK: 'locked', - WHEEL_PRESSED: 'wheelPressed', - }, - }, - locked: { - entry: actions.raise(dragModel.events.ENABLE_PANNING()), - exit: actions.raise(dragModel.events.DISABLE_PANNING()), - on: { RELEASE: 'released' }, - invoke: { - src: 'invokeDetectRelease', - }, - }, - wheelPressed: { - entry: actions.raise(((_ctx: any, ev: any) => - dragModel.events.ENABLE_PANNING(ev.data)) as any), - exit: actions.raise(dragModel.events.DISABLE_PANNING()), - on: { - DRAG_SESSION_STOPPED: 'released', - }, - }, - }, - on: { - ENABLE_PAN_MODE: 'pan', - }, - }, - pan: { - entry: actions.raise(dragModel.events.ENABLE_PANNING()), - exit: actions.raise(dragModel.events.DISABLE_PANNING()), - on: { - DISABLE_PAN_MODE: 'lockable', - }, - }, - }, - }, - panning: { - initial: 'disabled', - states: { - disabled: { - on: { - ENABLE_PANNING: 'enabled', - }, - }, - enabled: { - entry: 'disableTextSelection', - exit: 'enableTextSelection', - invoke: { - id: 'dragSessionTracker', - src: (ctx, ev) => - dragSessionTracker.withContext({ - ...dragSessionModel.initialContext, - ref: ctx.ref, - session: - // this is just defensive programming - // this really should receive ENABLE_PANNING at all times as this is the event that is making this state to be entered - // however, raised events are not given to invoke creators so we have to fallback handling WHEEL_PRESSED event - // in reality, because of this issue, ENABLE_PANNING that we can receive here won't ever hold any `sessionSeed` (as that is only coming from the wheel-oriented interaction) - ev.type === 'ENABLE_PANNING' - ? ev.sessionSeed - : ( - ev as Extract< - typeof ev, - { type: 'WHEEL_PRESSED' } - > - ).data, - }), - }, - on: { - DISABLE_PANNING: 'disabled', - }, - initial: 'idle', - states: { - idle: { - meta: { - cursor: 'grab', - }, - on: { - DRAG_SESSION_STARTED: 'active', - }, - }, - active: { - initial: 'grabbed', - on: { - DRAG_SESSION_STOPPED: '.done', - }, - states: { - grabbed: { - meta: { - cursor: 'grabbing', - }, - on: { - POINTER_MOVED_BY: { - target: 'dragging', - actions: 'sendPanChange', - }, - }, - }, - dragging: { - meta: { - cursor: 'grabbing', - }, - on: { - POINTER_MOVED_BY: { actions: 'sendPanChange' }, - }, - }, - done: { - type: 'final', - }, - }, - onDone: 'idle', - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - actions: { - disableTextSelection: (ctx) => { - const node = ctx.ref!.current!; - node.style.userSelect = 'none'; - }, - enableTextSelection: (ctx) => { - const node = ctx.ref!.current!; - node.style.userSelect = 'unset'; - }, - }, - services: { - wheelPressListener: (ctx) => (sendBack) => { - const node = ctx.ref!.current!; - const listener = (ev: PointerEvent) => { - if (ev.button === 1) { - sendBack( - dragModel.events.WHEEL_PRESSED({ - pointerId: ev.pointerId, - point: { - x: ev.pageX, - y: ev.pageY, - }, - }), - ); - } - }; - node.addEventListener('pointerdown', listener); - return () => node.removeEventListener('pointerdown', listener); - }, - invokeDetectLock: () => (sendBack) => { - function keydownListener(e: KeyboardEvent) { - const target = e.target as HTMLElement; - - if (e.code === 'Space' && !isAcceptingSpaceNatively(target)) { - e.preventDefault(); - sendBack('LOCK'); - } - } - window.addEventListener('keydown', keydownListener); - return () => { - window.removeEventListener('keydown', keydownListener); - }; - }, - invokeDetectRelease: () => (sendBack) => { - // TODO: we should release in more scenarios - // e.g.: - // - when the window blurs (without this we might get stuck in the locked state without Space actually being held down) - // - when unrelated keyboard keys get pressed (without this other actions might be executed while dragging which often might not be desired) - function keyupListener(e: KeyboardEvent) { - if (e.code === 'Space') { - e.preventDefault(); - sendBack('RELEASE'); - } - } +import { useCanvasDrag } from './CanvasDragContext'; +import { AnyState } from './types'; - window.addEventListener('keyup', keyupListener); - return () => { - window.removeEventListener('keyup', keyupListener); - }; - }, - }, - }, -); +const getCanvasRef = ( + state: ReturnType['initialState'], +) => state.context.ref; const getCursorByState = (state: AnyState) => ( @@ -272,40 +22,19 @@ const getCursorByState = (state: AnyState) => ) as { cursor?: CSSProperties['cursor'] } )?.cursor; -export const CanvasContainer: React.FC<{ panModeEnabled: boolean }> = ({ +export const CanvasContainer = ({ children, - panModeEnabled, + pannable, + zoomable, +}: { + children: React.ReactNode; + pannable: boolean; + zoomable: boolean; }) => { const canvasService = useCanvas(); - const embed = useEmbed(); - const canvasRef = useRef(null!); - const [state, send] = useMachine(dragMachine, { - actions: { - sendPanChange: actions.send( - (_, ev: any) => { - // we need to translate a pointer move to the viewbox move - // and that is going into the opposite direction than the pointer - return canvasModel.events.PAN(-ev.delta.x, -ev.delta.y); - }, - { to: canvasService as any }, - ), - }, - guards: { - isPanDisabled: () => !!embed?.isEmbedded && !embed.pan, - }, - context: { - ...dragModel.initialContext, - ref: canvasRef, - }, - }); - - React.useEffect(() => { - if (panModeEnabled) { - send(dragModel.events.ENABLE_PAN_MODE()); - } else { - send(dragModel.events.DISABLE_PAN_MODE()); - } - }, [panModeEnabled]); + const canvasDragService = useCanvasDrag(); + const canvasRef = useSelector(canvasDragService, getCanvasRef); + const cursor = useSelector(canvasDragService, getCursorByState); /** * Observes the canvas's size and reports it to the canvasService @@ -327,7 +56,7 @@ export const CanvasContainer: React.FC<{ panModeEnabled: boolean }> = ({ }); }); - resizeObserver.observe(canvasRef.current); + resizeObserver.observe(canvasRef.current!); return () => { resizeObserver.disconnect(); @@ -422,10 +151,7 @@ export const CanvasContainer: React.FC<{ panModeEnabled: boolean }> = ({ */ useEffect(() => { const onCanvasWheel = (e: WheelEvent) => { - const isZoomEnabled = !embed?.isEmbedded || embed.zoom; - const isPanEnabled = !embed?.isEmbedded || embed.pan; - - if (isZoomEnabled && isWithPlatformMetaKey(e)) { + if (zoomable && isWithPlatformMetaKey(e)) { e.preventDefault(); if (e.deltaY > 0) { canvasService.send( @@ -442,24 +168,24 @@ export const CanvasContainer: React.FC<{ panModeEnabled: boolean }> = ({ ), ); } - } else if (isPanEnabled && !e.metaKey && !e.ctrlKey) { + } else if (pannable && !e.metaKey && !e.ctrlKey) { e.preventDefault(); canvasService.send(canvasModel.events.PAN(e.deltaX, e.deltaY)); } }; - const canvasEl = canvasRef.current; + const canvasEl = canvasRef.current!; canvasEl.addEventListener('wheel', onCanvasWheel); return () => { canvasEl.removeEventListener('wheel', onCanvasWheel); }; - }, [canvasService, embed]); + }, [canvasService, zoomable, pannable]); return (
diff --git a/src/CanvasDragContext.tsx b/src/CanvasDragContext.tsx new file mode 100644 index 00000000..6a0da557 --- /dev/null +++ b/src/CanvasDragContext.tsx @@ -0,0 +1,312 @@ +import { InterpreterFrom } from 'xstate'; +import { useInterpret } from '@xstate/react'; +import { useRef } from 'react'; +import { createInterpreterContext } from './utils'; +import { createModel } from 'xstate/lib/model'; +import { useCanvas } from './CanvasContext'; +import { canvasModel, ZoomFactor } from './canvasMachine'; +import { + dragSessionModel, + dragSessionTracker, + DragSession, + PointDelta, +} from './dragSessionTracker'; +import { useMachine, useSelector } from '@xstate/react'; +import { actions } from 'xstate'; +import { Point } from './pathUtils'; +import { + isAcceptingArrowKey, + isAcceptingSpaceNatively, + isTextInputLikeElement, + isWithPlatformMetaKey, +} from './utils'; + +const dragModel = createModel( + { + ref: null as unknown as React.RefObject, + }, + { + events: { + LOCK: () => ({}), + RELEASE: () => ({}), + ENABLE_PANNING: (sessionSeed: DragSession | null = null) => ({ + sessionSeed, + }), + DISABLE_PANNING: () => ({}), + ENABLE_PAN_MODE: () => ({}), + DISABLE_PAN_MODE: () => ({}), + DRAG_SESSION_STARTED: ({ point }: { point: Point }) => ({ + point, + }), + DRAG_SESSION_STOPPED: () => ({}), + POINTER_MOVED_BY: ({ delta }: { delta: PointDelta }) => ({ + delta, + }), + WHEEL_PRESSED: (data: DragSession) => ({ data }), + WHEEL_RELEASED: () => ({}), + }, + }, +); + +const dragMachine = dragModel.createMachine( + { + preserveActionOrder: true, + initial: 'checking_if_disabled', + states: { + checking_if_disabled: { + always: [ + { + target: 'permanently_disabled', + cond: 'isPanDisabled', + }, + 'enabled', + ], + }, + permanently_disabled: {}, + enabled: { + type: 'parallel', + states: { + mode: { + initial: 'lockable', + states: { + lockable: { + initial: 'released', + states: { + released: { + invoke: [ + { + src: 'invokeDetectLock', + }, + { + src: 'wheelPressListener', + }, + ], + on: { + LOCK: 'locked', + WHEEL_PRESSED: 'wheelPressed', + }, + }, + locked: { + entry: actions.raise(dragModel.events.ENABLE_PANNING()), + exit: actions.raise(dragModel.events.DISABLE_PANNING()), + on: { RELEASE: 'released' }, + invoke: { + src: 'invokeDetectRelease', + }, + }, + wheelPressed: { + entry: actions.raise(((_ctx: any, ev: any) => + dragModel.events.ENABLE_PANNING(ev.data)) as any), + exit: actions.raise(dragModel.events.DISABLE_PANNING()), + on: { + DRAG_SESSION_STOPPED: 'released', + }, + }, + }, + on: { + ENABLE_PAN_MODE: 'pan', + }, + }, + pan: { + tags: ['panMode'], + entry: actions.raise(dragModel.events.ENABLE_PANNING()), + exit: actions.raise(dragModel.events.DISABLE_PANNING()), + on: { + DISABLE_PAN_MODE: 'lockable', + }, + }, + }, + }, + panning: { + initial: 'disabled', + states: { + disabled: { + on: { + ENABLE_PANNING: 'enabled', + }, + }, + enabled: { + entry: 'disableTextSelection', + exit: 'enableTextSelection', + invoke: { + id: 'dragSessionTracker', + src: (ctx, ev) => + dragSessionTracker.withContext({ + ...dragSessionModel.initialContext, + ref: ctx.ref, + session: + // this is just defensive programming + // this really should receive ENABLE_PANNING at all times as this is the event that is making this state to be entered + // however, raised events are not given to invoke creators so we have to fallback handling WHEEL_PRESSED event + // in reality, because of this issue, ENABLE_PANNING that we can receive here won't ever hold any `sessionSeed` (as that is only coming from the wheel-oriented interaction) + ev.type === 'ENABLE_PANNING' + ? ev.sessionSeed + : ( + ev as Extract< + typeof ev, + { type: 'WHEEL_PRESSED' } + > + ).data, + }), + }, + on: { + DISABLE_PANNING: 'disabled', + }, + initial: 'idle', + states: { + idle: { + meta: { + cursor: 'grab', + }, + on: { + DRAG_SESSION_STARTED: 'active', + }, + }, + active: { + initial: 'grabbed', + on: { + DRAG_SESSION_STOPPED: '.done', + }, + states: { + grabbed: { + meta: { + cursor: 'grabbing', + }, + on: { + POINTER_MOVED_BY: { + target: 'dragging', + actions: 'sendPanChange', + }, + }, + }, + dragging: { + meta: { + cursor: 'grabbing', + }, + on: { + POINTER_MOVED_BY: { actions: 'sendPanChange' }, + }, + }, + done: { + type: 'final', + }, + }, + onDone: 'idle', + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + actions: { + disableTextSelection: (ctx) => { + const node = ctx.ref!.current!; + node.style.userSelect = 'none'; + }, + enableTextSelection: (ctx) => { + const node = ctx.ref!.current!; + node.style.userSelect = 'unset'; + }, + }, + services: { + wheelPressListener: (ctx) => (sendBack) => { + const node = ctx.ref!.current!; + const listener = (ev: PointerEvent) => { + if (ev.button === 1) { + sendBack( + dragModel.events.WHEEL_PRESSED({ + pointerId: ev.pointerId, + point: { + x: ev.pageX, + y: ev.pageY, + }, + }), + ); + } + }; + node.addEventListener('pointerdown', listener); + return () => node.removeEventListener('pointerdown', listener); + }, + invokeDetectLock: () => (sendBack) => { + function keydownListener(e: KeyboardEvent) { + const target = e.target as HTMLElement; + + if (e.code === 'Space' && !isAcceptingSpaceNatively(target)) { + e.preventDefault(); + sendBack('LOCK'); + } + } + + window.addEventListener('keydown', keydownListener); + return () => { + window.removeEventListener('keydown', keydownListener); + }; + }, + invokeDetectRelease: () => (sendBack) => { + // TODO: we should release in more scenarios + // e.g.: + // - when the window blurs (without this we might get stuck in the locked state without Space actually being held down) + // - when unrelated keyboard keys get pressed (without this other actions might be executed while dragging which often might not be desired) + function keyupListener(e: KeyboardEvent) { + if (e.code === 'Space') { + e.preventDefault(); + sendBack('RELEASE'); + } + } + + window.addEventListener('keyup', keyupListener); + return () => { + window.removeEventListener('keyup', keyupListener); + }; + }, + }, + }, +); + +const [_CanvasDragProvider, useCanvasDrag, createCanvasDragSelector] = + createInterpreterContext>('CanvasDrag'); + +export const CanvasDragProvider = ({ + children, + pannable = true, +}: { + children: React.ReactNode; + pannable?: boolean; +}) => { + const canvasService = useCanvas(); + const canvasRef = useRef(null!); + const canvasDragService = useInterpret(dragMachine, { + actions: { + sendPanChange: actions.send( + (_, ev: any) => { + // we need to translate a pointer move to the viewbox move + // and that is going into the opposite direction than the pointer + return canvasModel.events.PAN(-ev.delta.x, -ev.delta.y); + }, + { to: canvasService as any }, + ), + }, + guards: { + isPanDisabled: () => !pannable, + }, + context: { + ...dragModel.initialContext, + ref: canvasRef, + }, + }); + + return ( + <_CanvasDragProvider value={canvasDragService}> + {children} + + ); +}; + +export { useCanvasDrag }; +export const useIsPanModeEnabled = createCanvasDragSelector((state) => + state.hasTag('panMode'), +); diff --git a/src/CanvasView.tsx b/src/CanvasView.tsx index ed43da9d..c13088cf 100644 --- a/src/CanvasView.tsx +++ b/src/CanvasView.tsx @@ -1,237 +1,195 @@ -import { - AddIcon, - MinusIcon, - RepeatIcon, - QuestionOutlineIcon, -} from '@chakra-ui/icons'; +import { AddIcon, MinusIcon, RepeatIcon } from '@chakra-ui/icons'; import { Box, Button, ButtonGroup, IconButton, - Link, - Menu, - MenuButton, - MenuItem, - MenuList, - Portal, Spinner, VStack, } from '@chakra-ui/react'; import { useSelector } from '@xstate/react'; -import xstatePkgJson from 'xstate/package.json'; import React, { useMemo } from 'react'; import { CanvasContainer } from './CanvasContainer'; import { useCanvas } from './CanvasContext'; -import { canZoom, canZoomIn, canZoomOut } from './canvasMachine'; +import { + CanvasDragProvider, + useCanvasDrag, + useIsPanModeEnabled, +} from './CanvasDragContext'; +import { canZoomIn, canZoomOut } from './canvasMachine'; import { toDirectedGraph } from './directedGraph'; import { Graph } from './Graph'; -import { useSimulation, useSimulationMode } from './SimulationContext'; -import { CanvasHeader } from './CanvasHeader'; -import { Overlay } from './Overlay'; -import { useEmbed } from './embedContext'; import { CompressIcon, HandIcon } from './Icons'; -import { useSourceActor } from './sourceMachine'; -import { WelcomeArea } from './WelcomeArea'; +import { Overlay } from './Overlay'; +import { useSimulation, useSimulationMode } from './SimulationContext'; -export const CanvasView: React.FC = () => { - // TODO: refactor this so an event can be explicitly sent to a machine - // it isn't straightforward to do at the moment cause the target machine lives in a child component - const [panModeEnabled, setPanModeEnabled] = React.useState(false); - const embed = useEmbed(); - const simService = useSimulation(); +const CanvasControlButtons = ({ + zoomable, + pannable, + AdditionalMenu, +}: { + pannable: boolean; + zoomable: boolean; + AdditionalMenu?: React.ReactNode; +}) => { const canvasService = useCanvas(); - const [sourceState] = useSourceActor(); - const machine = useSelector(simService, (state) => { - return state.context.currentSessionId - ? state.context.serviceDataMap[state.context.currentSessionId!]?.machine - : undefined; - }); - const isLayoutPending = useSelector(simService, (state) => - state.hasTag('layoutPending'), - ); - const isEmpty = useSelector(simService, (state) => state.hasTag('empty')); - const digraph = useMemo( - () => (machine ? toDirectedGraph(machine) : undefined), - [machine], - ); - const shouldEnableZoomOutButton = useSelector( - canvasService, - (state) => canZoom(embed) && canZoomOut(state.context), + const shouldEnableZoomOutButton = useSelector(canvasService, (state) => + canZoomOut(state.context), ); - - const shouldEnableZoomInButton = useSelector( - canvasService, - (state) => canZoom(embed) && canZoomIn(state.context), + const shouldEnableZoomInButton = useSelector(canvasService, (state) => + canZoomIn(state.context), ); - const simulationMode = useSimulationMode(); - - const canShowWelcomeMessage = sourceState.hasTag('canShowWelcomeMessage'); + const canvasDragService = useCanvasDrag(); + const panModeEnabled = useIsPanModeEnabled(); - const showControls = useMemo( - () => !embed?.isEmbedded || embed.controls, - [embed], - ); - - const showZoomButtonsInEmbed = useMemo( - () => !embed?.isEmbedded || (embed.controls && embed.zoom), - [embed], - ); - const showPanButtonInEmbed = useMemo( - () => !embed?.isEmbedded || (embed.controls && embed.pan), - [embed], - ); + const simService = useSimulation(); + const simulationMode = useSimulationMode(); return ( - {!embed?.isEmbedded && ( - - - - )} - - {digraph && } - {isLayoutPending && ( - - - - - Visualizing machine... - - - - )} - {isEmpty && canShowWelcomeMessage && } - - - {showControls && ( - - - {showZoomButtonsInEmbed && ( - <> - } - disabled={!shouldEnableZoomOutButton} - onClick={() => canvasService.send('ZOOM.OUT')} - variant="secondary" - /> - } - disabled={!shouldEnableZoomInButton} - onClick={() => canvasService.send('ZOOM.IN')} - variant="secondary" - /> - - )} + + {zoomable && ( + <> } - onClick={() => canvasService.send('FIT_TO_CONTENT')} + aria-label="Zoom out" + title="Zoom out" + icon={} + disabled={!shouldEnableZoomOutButton} + onClick={() => canvasService.send('ZOOM.OUT')} variant="secondary" /> - {!embed?.isEmbedded && ( - } - onClick={() => canvasService.send('POSITION.RESET')} - variant="secondary" - /> - )} - - {showPanButtonInEmbed && ( } - size="sm" - marginLeft={2} - onClick={() => setPanModeEnabled((v) => !v)} - aria-pressed={panModeEnabled} - variant={panModeEnabled ? 'secondaryPressed' : 'secondary'} - /> - )} - {simulationMode === 'visualizing' && ( - - )} - {!embed?.isEmbedded && ( - - - } - /> - - - - Report an issue - - - {`XState version ${xstatePkgJson.version}`} - - - Privacy Policy - - - - - )} - + /> + + )} + } + onClick={() => canvasService.send('FIT_TO_CONTENT')} + variant="secondary" + /> + } + onClick={() => canvasService.send('POSITION.RESET')} + variant="secondary" + /> + + {pannable && ( + } + size="sm" + marginLeft={2} + onClick={() => { + if (panModeEnabled) { + canvasDragService.send({ type: 'ENABLE_PAN_MODE' }); + } else { + canvasDragService.send({ type: 'DISABLE_PAN_MODE' }); + } + }} + aria-pressed={panModeEnabled} + variant={panModeEnabled ? 'secondaryPressed' : 'secondary'} + /> )} + {simulationMode === 'visualizing' && ( + + )} + {AdditionalMenu} ); }; + +export const CanvasView = ({ + pannable = true, + zoomable = true, + showControls = true, + Empty, + Header = null, + ControlsAdditionalMenu, +}: { + showControls?: boolean; + pannable?: boolean; + zoomable?: boolean; + Empty: React.ReactNode; + Header?: React.ReactNode; + ControlsAdditionalMenu?: React.ReactNode; +}) => { + const simService = useSimulation(); + const machine = useSelector(simService, (state) => { + return state.context.currentSessionId + ? state.context.serviceDataMap[state.context.currentSessionId!]?.machine + : undefined; + }); + const isLayoutPending = useSelector(simService, (state) => + state.hasTag('layoutPending'), + ); + const isEmpty = useSelector(simService, (state) => state.hasTag('empty')); + const digraph = useMemo( + () => (machine ? toDirectedGraph(machine) : undefined), + [machine], + ); + + return ( + + + {Header} + + {digraph && } + {isLayoutPending && ( + + + + + Visualizing machine... + + + + )} + {isEmpty && Empty} + + {showControls && ( + + )} + + + ); +}; diff --git a/src/CommonAppProviders.tsx b/src/CommonAppProviders.tsx new file mode 100644 index 00000000..4928809b --- /dev/null +++ b/src/CommonAppProviders.tsx @@ -0,0 +1,22 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { useInterpret } from '@xstate/react'; +import { SimulationProvider } from './SimulationContext'; +import { simulationMachine } from './simulationMachine'; +import { theme } from './theme'; +import { EditorThemeProvider } from './themeContext'; + +export const CommonAppProviders = ({ + children, +}: { + children: React.ReactNode; +}) => { + // don't use `devTools: true` here as it would freeze your browser + const simService = useInterpret(simulationMachine); + return ( + + + {children} + + + ); +}; diff --git a/src/DelayViz.scss b/src/DelayViz.scss deleted file mode 100644 index 443f6456..00000000 --- a/src/DelayViz.scss +++ /dev/null @@ -1,30 +0,0 @@ -[data-viz='delay'] { - opacity: 0.5; - transition: opacity 0.3s ease; - - > [data-viz='delay-circle'], - > [data-viz='delay-fill'] { - transition: opacity 0.3s ease; - stroke-width: 20; - } - - &[data-viz-active] { - opacity: 1; - > [data-viz='delay-fill'] { - animation: delay-progress calc(var(--delay, 0) * 1ms) both linear; - } - } -} - -[data-viz='delay-circle'] { - opacity: 0.4; -} - -@keyframes delay-progress { - from { - stroke-dashoffset: 1; - } - to { - stroke-dashoffset: 0; - } -} diff --git a/src/DelayViz.tsx b/src/DelayViz.tsx index 25cc11f4..e12bb08b 100644 --- a/src/DelayViz.tsx +++ b/src/DelayViz.tsx @@ -1,14 +1,26 @@ +import { chakra, keyframes } from '@chakra-ui/react'; import React from 'react'; +const delayProgress = keyframes` + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +`; + export const DelayViz: React.FC<{ active: boolean; duration: number }> = ({ active, duration, }) => { return ( - = ({ '--duration': duration, }} > - = ({ stroke="#fff" fill="transparent" /> - = ({ stroke="#fff" fill="transparent" /> - + ); }; diff --git a/src/EdgeViz.scss b/src/EdgeViz.scss deleted file mode 100644 index 285a9d32..00000000 --- a/src/EdgeViz.scss +++ /dev/null @@ -1,14 +0,0 @@ -[data-viz='edgeGroup'] { - &:not([data-viz-active]) { - opacity: 0.25; - } -} - -[data-viz='edge'] { - stroke: var(--viz-edge-color); -} - -[data-viz='edge-arrow'], -[data-viz='initialEdge-circle'] { - fill: var(--viz-edge-color); -} diff --git a/src/EdgeViz.tsx b/src/EdgeViz.tsx index 58029cc8..b8ab765f 100644 --- a/src/EdgeViz.tsx +++ b/src/EdgeViz.tsx @@ -62,19 +62,17 @@ export const EdgeViz: React.FC<{ edge: DirectedGraphEdge; order: number }> = ({ return path ? ( diff --git a/src/EditorPanel.tsx b/src/EditorPanel.tsx index 4f6f1a19..8ceb1d6e 100644 --- a/src/EditorPanel.tsx +++ b/src/EditorPanel.tsx @@ -83,7 +83,7 @@ const editorPanelModel = createModel( notifRef: undefined! as ActorRefFrom, monacoRef: null as Monaco | null, standaloneEditorRef: null as editor.IStandaloneCodeEditor | null, - sourceRef: null as SourceMachineActorRef, + sourceRef: null as unknown as SourceMachineActorRef, mainFile: 'main.ts', machines: null as AnyStateMachine[] | null, deltaDecorations: [] as string[], @@ -299,6 +299,7 @@ const editorPanelMachine = editorPanelModel.createMachine( range, options: { isWholeLine: true, + // TODO: recheck if those are actually working glyphMarginClassName: 'editor__glyph-margin', className: 'editor__error-content', }, @@ -455,6 +456,7 @@ export const EditorPanel: React.FC<{ onSave={() => { onSave(); }} + readOnly={embed?.isEmbedded && embed.readOnly} />
diff --git a/src/EditorWithXStateImports.tsx b/src/EditorWithXStateImports.tsx index c726eb51..54cc3ba2 100644 --- a/src/EditorWithXStateImports.tsx +++ b/src/EditorWithXStateImports.tsx @@ -2,7 +2,6 @@ import Editor, { Monaco, OnMount } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import { useEffect, useRef } from 'react'; import { themes } from './editor-themes'; -import { useEmbed } from './embedContext'; import { localCache } from './localCache'; import { prettierLoader } from './prettier'; import { SpinnerWithText } from './SpinnerWithText'; @@ -22,6 +21,7 @@ interface EditorWithXStateImportsProps { onSave?: () => void; onFormat?: () => void; value: string; + readOnly?: boolean; } // based on the logic here: https://github.com/microsoft/TypeScript-Website/blob/103f80e7490ad75c34917b11e3ebe7ab9e8fc418/packages/sandbox/src/index.ts @@ -66,10 +66,10 @@ const withTypeAcquisition = ( return editor; }; -export const EditorWithXStateImports = ( - props: EditorWithXStateImportsProps, -) => { - const embed = useEmbed(); +export const EditorWithXStateImports = ({ + readOnly, + ...props +}: EditorWithXStateImportsProps) => { const editorTheme = useEditorTheme(); const editorRef = useRef(null); const definedEditorThemes = useRef(new Set()); @@ -99,7 +99,7 @@ export const EditorWithXStateImports = ( minimap: { enabled: false }, tabSize: 2, glyphMargin: true, - readOnly: embed?.isEmbedded && embed.readOnly, + readOnly, }} loading={} onChange={(text) => { diff --git a/src/EventTypeViz.scss b/src/EventTypeViz.scss deleted file mode 100644 index 6a48c604..00000000 --- a/src/EventTypeViz.scss +++ /dev/null @@ -1,30 +0,0 @@ -[data-viz='eventType'] { - white-space: nowrap; - overflow: hidden; - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - gap: 1ch; - --viz-eventType-dot-color: transparent; - - &[data-viz-keyword='done'] { - --viz-eventType-dot-color: #33ff99; - } - &[data-viz-keyword='error'] { - --viz-eventType-dot-color: #e76f4b; - } - &[data-viz-keyword='always'], - &[data-viz-keyword='after'] { - --viz-eventType-dot-color: #fff; - } - - &[data-viz-keyword]:before { - content: ''; - height: 0.5rem; - width: 0.5rem; - border-radius: 0.5rem; - background-color: var(--viz-eventType-dot-color); - display: block; - } -} diff --git a/src/EventTypeViz.tsx b/src/EventTypeViz.tsx index 29365408..1cbcf3d4 100644 --- a/src/EventTypeViz.tsx +++ b/src/EventTypeViz.tsx @@ -1,6 +1,9 @@ import React from 'react'; import type { InvokeDefinition } from 'xstate/lib/types'; import type { DelayedTransitionMetadata } from './TransitionViz'; +import { ActionLabelBeforeElement } from './ActionLabelBeforeElement'; +import { SpecificActionLabel } from './SpecificActionLabel'; +import { chakra } from '@chakra-ui/react'; export function toDelayString(delay: string | number): string { if (typeof delay === 'number' || !isNaN(+delay)) { @@ -37,13 +40,48 @@ export function InvokeViz({ invoke }: { invoke: InvokeDefinition }) { const id = unnamed ? 'anonymous' : invoke.id; return ( -
+ {unnamed ? {id} : id} + + ); +} + +function EventTypeWrapper({ + children, + keyword, +}: { + children: React.ReactNode; + keyword?: 'done' | 'error' | 'after' | 'always'; +}) { + return ( + -
{unnamed ? {id} : id}
-
+ {children} + ); } @@ -54,58 +92,53 @@ export const EventTypeViz: React.FC<{ }> = ({ eventType: event, delay, onChangeEventType }) => { if (event.startsWith('done.state.')) { return ( -
- onDone -
+ + onDone + ); } if (event.startsWith('done.invoke.')) { const match = event.match(/^done\.invoke\.(.+)$/); return ( -
- done:{' '} -
- {match ? formatInvocationId(match[1]) : '??'} -
-
+ + done:
{match ? formatInvocationId(match[1]) : '??'}
+
); } if (event.startsWith('error.platform.')) { const match = event.match(/^error\.platform\.(.+)$/); return ( -
- error:{' '} -
{match ? match[1] : '??'}
-
+ + error:
{match ? match[1] : '??'}
+
); } if (delay?.delayType === 'DELAYED_INVALID') { - return
{event}
; + return {event}; } if (delay?.delayType === 'DELAYED_VALID') { return ( -
- after{' '} -
{delay.delayString}
-
+ + after
{delay.delayString}
+
); } if (event === '') { return ( -
- always -
+ + always + ); } return ( -
-
{event}
-
+ +
{event}
+
); }; diff --git a/src/InitialEdgeViz.tsx b/src/InitialEdgeViz.tsx index 265e8b08..5006bdbc 100644 --- a/src/InitialEdgeViz.tsx +++ b/src/InitialEdgeViz.tsx @@ -31,23 +31,21 @@ export const InitialEdgeViz: React.FC<{ node: DirectedGraphNode }> = ({ const markerId = `n${Math.floor(Math.random() * 1000)}`; return ( - + { - const embed = useEmbed(); - const simService = useSimulation(); - const services = useSelector(simService, selectServices); - const [sourceState, sendToSourceService] = useSourceActor(); - const [activePanelIndex, setActiveTabIndex] = useState(() => - embed?.isEmbedded ? calculatePanelIndexByPanelName(embed.panel) : 0, - ); - - useEffect(() => { - if (embed?.isEmbedded) { - setActiveTabIndex(calculatePanelIndexByPanelName(embed.panel)); - } - }, [embed]); +export const PanelsView = ({ + defaultIndex = 0, + resizable = true, + tabs, + tabListRightButtons, + ...props +}: BoxProps & { + defaultIndex?: number; + resizable?: boolean; + tabs: Array<{ + Tab: React.ComponentType; + TabPanel: React.ComponentType; + }>; + tabListRightButtons: React.ReactNode; +}) => { return ( diff --git a/src/ResizableBox.tsx b/src/ResizableBox.tsx index 497f61d2..995935b0 100644 --- a/src/ResizableBox.tsx +++ b/src/ResizableBox.tsx @@ -12,7 +12,7 @@ import { Point } from './types'; const resizableModel = createModel( { - ref: null as React.MutableRefObject | null, + ref: null as React.RefObject | null, widthDelta: 0, }, { @@ -34,7 +34,7 @@ const resizableMachine = resizableModel.createMachine({ src: (ctx) => dragSessionTracker.withContext({ ...dragSessionModel.initialContext, - ref: ctx.ref, + ref: ctx.ref!, }), }, initial: 'idle', diff --git a/src/RootContainer.tsx b/src/RootContainer.tsx new file mode 100644 index 00000000..ae7426ec --- /dev/null +++ b/src/RootContainer.tsx @@ -0,0 +1,25 @@ +import { Box } from '@chakra-ui/react'; + +export const RootContainer = ({ + canvas, + panels, +}: { + canvas: React.ReactNode; + panels: React.ReactNode; +}) => { + if (!canvas && !panels) { + throw new Error('Either canvas or panels must be enabled.'); + } + return ( + + {canvas} + {panels} + + ); +}; diff --git a/src/SpecificActionLabel.tsx b/src/SpecificActionLabel.tsx new file mode 100644 index 00000000..1d119397 --- /dev/null +++ b/src/SpecificActionLabel.tsx @@ -0,0 +1,24 @@ +import { Box } from '@chakra-ui/react'; + +export function SpecificActionLabel({ + children, + title, +}: { + children: React.ReactNode; + title?: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/StateNodeViz.scss b/src/StateNodeViz.scss deleted file mode 100644 index 70c0875f..00000000 --- a/src/StateNodeViz.scss +++ /dev/null @@ -1,184 +0,0 @@ -[data-viz='stateNodeGroup'] { - --viz-node-border-color: var(--viz-border-color); - --viz-node-active: 0; - --viz-transition-color: #555; - - &[data-viz-active] { - --viz-node-border-color: var(--viz-color-active); - --viz-node-active: 1; - --viz-transition-color: var(--viz-color-active); - } - - &[data-viz-previewed]:not([data-viz-active]) { - --viz-node-border-color: var(--viz-color-active); - } -} - -[data-viz='stateNode'] { - color: var(--viz-color-fg); - align-self: start; - opacity: calc(0.7 * (1 - var(--viz-node-active)) + var(--viz-node-active)); - font-size: 1em; - border-radius: var(--viz-radius); - overflow: hidden; - - // Border in a pseudoelement to not affect positioning - &:before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - border: var(--viz-border); - border-color: var(--viz-node-border-color); - border-style: var(--viz-node-border-style); - border-radius: inherit; - z-index: 1; - pointer-events: none; - } - - &[data-viz-parent-type='parallel'] { - --viz-node-border-style: var(--viz-node-parallel-border-style); - } - - &:not([data-viz-parent-type='parallel']) { - --viz-node-border-style: solid; - } -} - -[data-viz='stateNode-header'] { - display: grid; - grid-template-columns: auto 1fr auto; - grid-template-areas: 'type key tags'; - align-items: center; - - > [data-viz='stateNode-key'] { - grid-area: key; - } - - > [data-viz='stateNode-tags'] { - grid-area: tags; - } -} - -[data-viz='stateNode-content'] { - background: var(--viz-node-color-bg); - - &:empty { - display: none; - } -} - -[data-viz='stateNode-states'] { - padding: 2rem; - display: flex; - flex-wrap: wrap; - gap: 2rem; - position: absolute; - top: 0; - left: 0; - - &:empty { - display: none; - } -} - -[data-viz='stateNode-type'] { - height: 1.5rem; - width: 1.5rem; - margin: 0.5rem; - margin-right: 0; - border-radius: var(--viz-radius); - background: var(--viz-color-transparent); - display: flex; - justify-content: center; - align-items: center; - - &::before { - content: var(--viz-stateNode-type); - display: block; - font-weight: bold; - } - - &[data-viz-type='final'] { - border: 2px solid var(--viz-color-transparent); - background: transparent; - - &:before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - border-radius: inherit; - transform: scale(0.7); - background: var(--viz-color-transparent); - } - } - - &[data-viz-type='history'] { - --viz-stateNode-type: 'H'; - - &[data-viz-history='deep'] { - --viz-stateNode-type: 'H٭'; - font-size: 80%; - } - } -} - -[data-viz='stateNode-key'] { - padding: 0.5rem; - font-weight: bold; -} - -[data-viz='stateNode-keyText'] { - max-width: 20ch; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -[data-viz='stateNode-actions'] { - padding: 0.5rem; - - &:empty { - display: none; - } -} - -[data-viz='stateNode-invocations'] { - padding: 0.5rem; -} - -[data-viz='stateNode-tags'] { - display: flex; - flex-direction: row; - text-overflow: ellipsis; - padding: 0.5rem; -} - -[data-viz='stateNode-tag'] { - display: inline-block; - font-size: 0.75rem; - font-weight: bold; - padding: 0.25rem; - border-radius: 0.25rem; - background-color: #fff5; - - & + & { - margin-left: 1ch; - } -} - -[data-viz='stateNode-meta'] { - border-top: 2px solid var(--chakra-colors-whiteAlpha-300); - padding: 0.5rem; - min-width: max-content; - font-size: var(--chakra-fontSizes-sm); - - > p { - max-width: 10rem; - } -} diff --git a/src/StateNodeViz.tsx b/src/StateNodeViz.tsx index 2833b8ca..bf567085 100644 --- a/src/StateNodeViz.tsx +++ b/src/StateNodeViz.tsx @@ -1,4 +1,4 @@ -import { Link } from '@chakra-ui/react'; +import { chakra, Link } from '@chakra-ui/react'; import { useActor } from '@xstate/react'; import React, { useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; @@ -45,11 +45,25 @@ type StateNodeDef = const StateNodeKey: React.FC<{ value: string }> = ({ value }) => { return ( -
-
+ + {value} -
-
+ + ); }; @@ -83,93 +97,210 @@ export const StateNodeViz: React.FC<{ } const description = stateNode.description || stateNode.meta?.description; + const isActive = !!simState.configuration.find((n) => n.id === stateNode.id); + const isPreviewed = !!previewState?.configuration.find( + (n) => n.id === stateNode.id, + ); return ( -
n.id === stateNode.id) || undefined - } - data-viz-previewed={ - previewState?.configuration.find((n) => n.id === stateNode.id) || - undefined - } - style={{ - // outline: '1px solid blue', + -
-
-
+ {['history', 'final'].includes(stateNode.type) && ( -
+ css={{ + height: '1.5rem', + width: '1.5rem', + margin: '0.5rem', + marginRight: 0, + borderRadius: 'var(--viz-radius)', + background: 'var(--viz-color-transparent)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + ...(stateNode.type === 'final' && { + border: '2px solid var(--viz-color-transparent)', + background: 'transparent', + }), + }} + _before={{ + content: + stateNode.type === 'final' + ? "''" + : stateNode.type === 'history' && + stateNode.history === 'deep' + ? "'H٭'" + : 'H', + display: 'block', + fontWeight: 'bold', + ...(stateNode.type === 'final' && { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: 'inherit', + transform: 'scale(0.7)', + background: 'var(--viz-color-transparent)', + }), + ...(stateNode.type === 'history' && + stateNode.history === 'deep' && { + fontSize: '80%', + }), + }} + >
)} {stateNode.tags.length > 0 && ( -
+ {stateNode.tags.map((tag, i) => { return ( -
+ {tag} -
+
); })} -
+ )} -
+ {stateNode.definition.invoke.length > 0 && ( -
+ {stateNode.definition.invoke.map((invokeDef) => { return ; })} -
+ )} {stateNode.definition.entry.length > 0 && ( -
+ {stateNode.definition.entry.map((action, index) => { return ; })} -
+ )} {stateNode.definition.exit.length > 0 && ( -
+ {stateNode.definition.exit.map((action, index) => { return ; })} -
+ )} {description && ( -
+ p': { + maxWidth: '10rem', + }, + }} + > , @@ -177,11 +308,21 @@ export const StateNodeViz: React.FC<{ > {description} -
+ )} -
- {'states' in stateNode && ( -
+ + {'states' in stateNode && node.children && ( + {node.children.map((childNode) => { return ( ); })} -
+ )} -
-
+ + ); }; diff --git a/src/TransitionViz.scss b/src/TransitionViz.scss deleted file mode 100644 index 5fc856d1..00000000 --- a/src/TransitionViz.scss +++ /dev/null @@ -1,88 +0,0 @@ -[data-viz='transition'] { - --viz-transition-color: gray; - display: block; - border-radius: 1rem; - background-color: var(--viz-transition-color); - appearance: none; - - &[data-viz-potential] { - --viz-transition-color: var(--viz-color-active); - } - - > [data-viz='transition-label'] { - align-self: center; - } - - &[data-is-delayed] { - &:not([data-viz-disabled]):after { - animation: move-left calc(var(--delay) * 1ms) linear; - z-index: 0; - } - } -} - -[data-viz='transition-label'] { - flex-shrink: 0; - font-size: var(--viz-font-size-sm); - font-weight: bold; - color: white; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - overflow: hidden; -} - -@keyframes move-left { - from { - transform: translateX(-100%); - } - to { - transform: translateX(0); - } -} - -[data-viz='transition-event'] { - display: flex; - flex-direction: row; - align-items: center; - gap: 1ch; - padding: 0.25rem 0.5rem; -} - -[data-viz='transition-guard'] { - padding: 0 0.5rem; - &:before { - content: '['; - } - &:after { - content: ']'; - } -} - -[data-viz='transition-actions'] { - &:empty { - display: none; - } -} - -[data-viz='transition-content'] { - &:empty { - display: none; - } - padding: 0rem 0.5rem 0.5rem; -} - -[data-viz='transition-description'] { - &:empty { - display: none; - } - border-top: 2px solid var(--chakra-colors-whiteAlpha-300); - padding: 0.5rem; - min-width: max-content; - font-size: var(--chakra-fontSizes-sm); - text-align: left; - > p { - max-width: 10rem; - } -} \ No newline at end of file diff --git a/src/TransitionViz.tsx b/src/TransitionViz.tsx index 6d91936b..07365e50 100644 --- a/src/TransitionViz.tsx +++ b/src/TransitionViz.tsx @@ -1,15 +1,25 @@ +import { chakra, keyframes } from '@chakra-ui/react'; import { useSelector } from '@xstate/react'; import React, { useMemo } from 'react'; import type { AnyStateNodeDefinition, Guard } from 'xstate'; +import { toSCXMLEvent } from 'xstate/lib/utils'; +import { ActionViz } from './ActionViz'; +import { DelayViz } from './DelayViz'; import { DirectedGraphEdge } from './directedGraph'; import { EventTypeViz, toDelayString } from './EventTypeViz'; import { Point } from './pathUtils'; import { useSimulation } from './SimulationContext'; -import { AnyStateMachine, StateFrom } from './types'; -import { toSCXMLEvent } from 'xstate/lib/utils'; import { simulationMachine } from './simulationMachine'; -import { ActionViz } from './ActionViz'; -import { DelayViz } from './DelayViz'; +import { AnyStateMachine, StateFrom } from './types'; + +const moveLeft = keyframes` + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +`; const getGuardType = (guard: Guard) => { return guard.name; // v4 @@ -106,11 +116,25 @@ export const TransitionViz: React.FC<{ !!state.configuration.find((sn) => sn === edge.source); return ( - + ); }; diff --git a/src/WebExtension.tsx b/src/WebExtension.tsx new file mode 100644 index 00000000..7acb9f35 --- /dev/null +++ b/src/WebExtension.tsx @@ -0,0 +1,29 @@ +import { useInterpret } from '@xstate/react'; +import { CanvasProvider } from './CanvasContext'; +import { canvasMachine } from './canvasMachine'; +import { CanvasView } from './CanvasView'; +import { CommonAppProviders } from './CommonAppProviders'; +import { RootContainer } from './RootContainer'; +import { ActorsTab } from './tabs/ActorsTab'; +import { EventsTab } from './tabs/EventsTab'; +import { SettingsTab } from './tabs/SettingsTab'; +import { StateTab } from './tabs/StateTab'; + +// TODO: implement something nicer +const Empty = () => null; + +export const WebExtension = () => { + const canvasService = useInterpret(canvasMachine); + return ( + + + } /> + + } + panels={[StateTab, EventsTab, ActorsTab, SettingsTab]} + /> + + ); +}; diff --git a/src/base.scss b/src/base.scss deleted file mode 100644 index d6495c66..00000000 --- a/src/base.scss +++ /dev/null @@ -1,29 +0,0 @@ -[data-viz-theme] { - background: var(--viz-color-bg); - color: var(--viz-color-fg); - - &[data-viz-theme='light'] { - --viz-color-bg: #fff; - --viz-color-fg: #111; - --viz-node-color-bg: #ddd; - --viz-edge-color: #111; - } - - &[data-viz-theme='dark'] { - --viz-color-fg: #fff; - --viz-color-bg: #111; - --viz-node-color-bg: #2d2d2d; - --viz-edge-color: white; - } - - --viz-color-transparent: #fff6; - --viz-color-active: #679ae7; - --viz-border-color: var(--viz-node-color-bg); - --viz-border-width: 2px; - --viz-border: var(--viz-border-width) solid var(--viz-border-color); - --viz-radius: 0.25rem; - --viz-node-border-style: solid; - --viz-node-parallel-border-style: dashed; - --viz-font-size-base: 14px; - --viz-font-size-sm: 12px; -} diff --git a/src/canvasMachine.tsx b/src/canvasMachine.tsx index 10c21bde..b1e599f9 100644 --- a/src/canvasMachine.tsx +++ b/src/canvasMachine.tsx @@ -1,9 +1,10 @@ import { send } from 'xstate'; +import { choose } from 'xstate/lib/actions'; import { createModel } from 'xstate/lib/model'; import { ModelEventsFrom } from 'xstate/lib/model.types'; import { StateElkNode } from './graphUtils'; import { localCache } from './localCache'; -import { EmbedContext, Point } from './types'; +import { Point } from './types'; export enum ZoomFactor { slow = 1.09, @@ -27,7 +28,10 @@ const initialPosition = { const initialContext = { ...initialPosition, elkGraph: undefined as StateElkNode | undefined, - embed: undefined as EmbedContext | undefined, + sourceID: null as string | null, + zoomable: true, + pannable: true, + persistablePosition: true, }; export interface Viewbox { @@ -84,20 +88,16 @@ const MAX_ZOOM_OUT_FACTOR = 0.1; const MAX_ZOOM_IN_FACTOR = 2; -export const canZoom = (embed?: EmbedContext) => { - return !embed?.isEmbedded || embed.zoom; -}; - export const canZoomOut = (ctx: typeof initialContext) => { - return ctx.zoom > MAX_ZOOM_OUT_FACTOR; + return ctx.zoomable && ctx.zoom > MAX_ZOOM_OUT_FACTOR; }; export const canZoomIn = (ctx: typeof initialContext) => { - return ctx.zoom < MAX_ZOOM_IN_FACTOR; + return ctx.zoomable && ctx.zoom < MAX_ZOOM_IN_FACTOR; }; -export const canPan = (ctx: typeof initialContext) => { - return !ctx.embed?.isEmbedded || (ctx.embed.isEmbedded && ctx.embed.pan); +const canPan = (ctx: typeof initialContext) => { + return ctx.pannable; }; const getCanvasCenterPoint = ({ @@ -162,7 +162,7 @@ export const canvasMachine = canvasModel.createMachine({ zoomFactor: calculateZoomOutFactor(e.zoomFactor), }); }), - cond: (ctx) => canZoom(ctx.embed) && canZoomOut(ctx), + cond: (ctx) => canZoomOut(ctx), target: '.throttling', internal: false, }, @@ -173,7 +173,7 @@ export const canvasMachine = canvasModel.createMachine({ zoomFactor: e.zoomFactor || DEFAULT_ZOOM_IN_FACTOR, }); }), - cond: (ctx) => canZoom(ctx.embed) && canZoomIn(ctx), + cond: (ctx) => canZoomIn(ctx), target: '.throttling', internal: false, }, @@ -241,17 +241,20 @@ export const canvasMachine = canvasModel.createMachine({ SOURCE_CHANGED: { target: '.throttling', internal: false, - actions: canvasModel.assign((context, event) => { - // TODO: This can be more elegant when we have system actor - if (!context.embed?.isEmbedded) { - const position = getPositionFromEvent(event); + actions: [ + canvasModel.assign({ sourceID: (_, { id }) => id }), + canvasModel.assign((context, event) => { + // TODO: This can be more elegant when we have system actor + if (context.persistablePosition) { + const position = getPositionFromEvent(event); - if (!position) return {}; + if (!position) return {}; - return position; - } - return {}; - }), + return position; + } + return {}; + }), + ], }, 'elkGraph.UPDATE': { actions: [ @@ -307,7 +310,13 @@ export const canvasMachine = canvasModel.createMachine({ }, saving: { always: { - actions: 'persistPositionToLocalStorage', + actions: choose([ + { + cond: (ctx) => ctx.persistablePosition, + actions: ({ sourceID, zoom, viewbox }) => + localCache.savePosition(sourceID, { zoom, viewbox }), + }, + ]), target: 'idle', }, }, diff --git a/src/dragSessionTracker.ts b/src/dragSessionTracker.ts index 53ed5ad1..6fd52af5 100644 --- a/src/dragSessionTracker.ts +++ b/src/dragSessionTracker.ts @@ -21,7 +21,7 @@ export interface PointDelta { export const dragSessionModel = createModel( { session: null as DragSession | null, - ref: null as React.MutableRefObject | null, + ref: null as unknown as React.RefObject, }, { events: { diff --git a/src/editor.scss b/src/editor.scss deleted file mode 100644 index b90284fd..00000000 --- a/src/editor.scss +++ /dev/null @@ -1,7 +0,0 @@ -.editor__glyph-margin { - background-color: var(--chakra-colors-red-600); -} - -.editor__error-content { - background-color: var(--chakra-colors-whiteAlpha-300); -} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c80d15d5..bb804eb4 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -5,15 +5,7 @@ import { useRouter } from 'next/router'; import { useInterpret } from '@xstate/react'; import { createAuthMachine } from '../authMachine'; import { AuthProvider } from '../authContext'; -import '../ActionViz.scss'; -import '../base.scss'; -import '../DelayViz.scss'; -import '../EdgeViz.scss'; -import '../EventTypeViz.scss'; -import '../InvokeViz.scss'; import '../monacoPatch'; -import '../StateNodeViz.scss'; -import '../TransitionViz.scss'; // import { isOnClientSide } from '../isOnClientSide'; diff --git a/src/ActorsPanel.tsx b/src/tabs/ActorsTab.tsx similarity index 80% rename from src/ActorsPanel.tsx rename to src/tabs/ActorsTab.tsx index e9cf7895..d8f03ff1 100644 --- a/src/ActorsPanel.tsx +++ b/src/tabs/ActorsTab.tsx @@ -1,25 +1,29 @@ -import React from 'react'; -import { useSelector } from '@xstate/react'; -import { useSimulation } from './SimulationContext'; +import { ArrowForwardIcon } from '@chakra-ui/icons'; import { Accordion, - AccordionItem, AccordionButton, - AccordionPanel, AccordionIcon, - Button, + AccordionItem, + AccordionPanel, + Badge, Box, + Button, List, - ListItem, ListIcon, + ListItem, + Tab, + TabPanel, + TabPanelProps, + TabProps, Text, - Link, } from '@chakra-ui/react'; -import { ArrowForwardIcon } from '@chakra-ui/icons'; +import { useSelector } from '@xstate/react'; +import React from 'react'; import { InterpreterStatus, StateFrom } from 'xstate'; -import { simulationMachine } from './simulationMachine'; +import { useSimulation } from '../SimulationContext'; +import { simulationMachine } from '../simulationMachine'; -export const selectServices = (state: StateFrom) => { +const selectServices = (state: StateFrom) => { return state.context.serviceDataMap; }; @@ -71,7 +75,7 @@ const ActorDetails: React.FC<{ state: any; title: string }> = ({ ); }; -export const ActorsPanel: React.FC = () => { +const ActorsPanel: React.FC = () => { const simActor = useSimulation(); const services = useSelector(simActor, selectServices); const currentSessionId = useSelector( @@ -129,3 +133,23 @@ export const ActorsPanel: React.FC = () => { ); }; + +export const ActorsTab = { + Tab: (props: TabProps) => { + const simService = useSimulation(); + const services = useSelector(simService, selectServices); + return ( + + Actors{' '} + + {Object.values(services).length} + + + ); + }, + TabPanel: (props: TabPanelProps) => ( + + + + ), +}; diff --git a/src/tabs/CodeTab.tsx b/src/tabs/CodeTab.tsx new file mode 100644 index 00000000..b5046b32 --- /dev/null +++ b/src/tabs/CodeTab.tsx @@ -0,0 +1,58 @@ +import { Tab, TabPanel, TabPanelProps, TabProps } from '@chakra-ui/react'; +import { EditorPanel } from '../EditorPanel'; +import { useSimulation } from '../SimulationContext'; +import { useSourceActor } from '../sourceMachine'; +import { SpinnerWithText } from '../SpinnerWithText'; + +export const CodeTab = { + Tab: (props: TabProps) => Code, + TabPanel: (props: TabPanelProps) => { + const [sourceState, sendToSourceService] = useSourceActor(); + const simService = useSimulation(); + return ( + + {sourceState.matches({ + with_source: 'loading_content', + }) && ( + + )} + {!sourceState.matches({ + with_source: 'loading_content', + }) && ( + { + sendToSourceService({ + type: 'CODE_UPDATED', + code, + sourceID: sourceState.context.sourceID, + }); + }} + onCreateNew={() => + sendToSourceService({ + type: 'CREATE_NEW', + }) + } + onSave={() => { + sendToSourceService({ + type: 'SAVE', + }); + }} + onChange={(machines) => { + simService.send({ + type: 'MACHINES.REGISTER', + machines, + }); + }} + onFork={() => { + sendToSourceService({ + type: 'FORK', + }); + }} + /> + )} + + ); + }, +}; diff --git a/src/EventsPanel.tsx b/src/tabs/EventsTab.tsx similarity index 96% rename from src/EventsPanel.tsx rename to src/tabs/EventsTab.tsx index debce197..32bc3678 100644 --- a/src/EventsPanel.tsx +++ b/src/tabs/EventsTab.tsx @@ -19,7 +19,11 @@ import { PopoverTrigger, Portal, Switch, + Tab, Table, + TabPanel, + TabPanelProps, + TabProps, Tbody, Td, Text, @@ -34,10 +38,10 @@ import React, { useEffect, useState } from 'react'; import { assign, SCXML, send, StateFrom } from 'xstate'; import { createModel } from 'xstate/lib/model'; import { toSCXMLEvent } from 'xstate/lib/utils'; -import { JSONView } from './JSONView'; -import { useSimulation } from './SimulationContext'; -import { SimEvent, simulationMachine } from './simulationMachine'; -import { isInternalEvent, isNullEvent } from './utils'; +import { JSONView } from '../JSONView'; +import { useSimulation } from '../SimulationContext'; +import { SimEvent, simulationMachine } from '../simulationMachine'; +import { isInternalEvent, isNullEvent } from '../utils'; const EventConnection: React.FC<{ event: SimEvent }> = ({ event }) => { const sim = useSimulation(); @@ -155,7 +159,7 @@ const selectMachine = (state: StateFrom) => ? state.context.serviceDataMap[state.context.currentSessionId] : undefined; // TODO: select() method on model -export const EventsPanel: React.FC = () => { +const EventsPanel: React.FC = () => { const sim = useSimulation(); const [state, send] = useActor(sim); const rawEvents = state.context!.events; @@ -477,3 +481,12 @@ const NewEvent: React.FC<{ ); }; + +export const EventsTab = { + Tab: (props: TabProps) => Events, + TabPanel: (props: TabPanelProps) => ( + + + + ), +}; diff --git a/src/SettingsPanel.tsx b/src/tabs/SettingsTab.tsx similarity index 77% rename from src/SettingsPanel.tsx rename to src/tabs/SettingsTab.tsx index 2b259598..0432180a 100644 --- a/src/SettingsPanel.tsx +++ b/src/tabs/SettingsTab.tsx @@ -1,20 +1,25 @@ +import { SettingsIcon } from '@chakra-ui/icons'; import { - Thead, - Tbody, - Table, - Tr, - Th, - Td, - Heading, Box, + Heading, Kbd, - VStack, Select, + Tab, + Table, + TabPanel, + TabPanelProps, + TabProps, + Tbody, + Td, + Th, + Thead, + Tr, + VStack, } from '@chakra-ui/react'; -import { ThemeName, themes } from './editor-themes'; -import { useEditorTheme } from './themeContext'; -import { useSimulationMode } from './SimulationContext'; -import { getPlatformMetaKeyLabel } from './utils'; +import { ThemeName, themes } from '../editor-themes'; +import { useSimulationMode } from '../SimulationContext'; +import { useEditorTheme } from '../themeContext'; +import { getPlatformMetaKeyLabel } from '../utils'; const KeyboardShortcuts = () => ( @@ -58,7 +63,8 @@ const KeyboardShortcuts = () => ( - Up , Left , Down , Right + Up , Left , Down ,{' '} + Right @@ -68,7 +74,7 @@ const KeyboardShortcuts = () => ( - Shift + 1 + Shift + 1 Fit to content @@ -87,7 +93,7 @@ const KeyboardShortcuts = () => ( ); -export const SettingsPanel: React.FC = () => { +const SettingsPanel: React.FC = () => { const editorTheme = useEditorTheme(); const simulationMode = useSimulationMode(); return ( @@ -115,3 +121,16 @@ export const SettingsPanel: React.FC = () => { ); }; + +export const SettingsTab = { + Tab: (props: TabProps) => ( + + + + ), + TabPanel: (props: TabPanelProps) => ( + + + + ), +}; diff --git a/src/StatePanel.tsx b/src/tabs/StateTab.tsx similarity index 79% rename from src/StatePanel.tsx rename to src/tabs/StateTab.tsx index b3f26d4e..93382305 100644 --- a/src/StatePanel.tsx +++ b/src/tabs/StateTab.tsx @@ -1,17 +1,21 @@ -import React from 'react'; -import { useSelector } from '@xstate/react'; -import { StateFrom } from 'xstate'; import { Accordion, - AccordionItem, AccordionButton, - AccordionPanel, AccordionIcon, + AccordionItem, + AccordionPanel, Box, + Tab, + TabPanel, + TabPanelProps, + TabProps, } from '@chakra-ui/react'; -import { useSimulation } from './SimulationContext'; -import { simulationMachine } from './simulationMachine'; -import { JSONView } from './JSONView'; +import { useSelector } from '@xstate/react'; +import React from 'react'; +import { StateFrom } from 'xstate'; +import { JSONView } from '../JSONView'; +import { useSimulation } from '../SimulationContext'; +import { simulationMachine } from '../simulationMachine'; const selectState = (state: StateFrom) => state.context.currentSessionId @@ -64,8 +68,17 @@ const StateAccordion: React.FC<{ state: any; title: string }> = ({ ); }; -export const StatePanel: React.FC = () => { +const StatePanel: React.FC = () => { const state = useSelector(useSimulation(), selectState); return ; }; + +export const StateTab = { + Tab: (props: TabProps) => State, + TabPanel: (props: TabPanelProps) => ( + + + + ), +}; diff --git a/src/theme.ts b/src/theme.ts index e279a2bd..30ca139c 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -18,6 +18,25 @@ export const theme = extendTheme({ overflow: 'hidden', overscrollBehavior: 'none', fontFamily: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif`, + + background: 'var(--viz-color-bg)', + color: 'var(--viz-color-fg)', + + '--viz-color-transparent': '#fff6', + '--viz-color-active': '#679ae7', + '--viz-border-color': 'var(--viz-node-color-bg)', + '--viz-border-width': '2px', + '--viz-border': 'var(--viz-border-width) solid var(--viz-border-color)', + '--viz-radius': '0.25rem', + '--viz-node-border-style': 'solid', + '--viz-node-parallel-border-style': 'dashed', + '--viz-font-size-base': '14px', + '--viz-font-size-sm': '12px', + + '--viz-color-fg': '#fff', + '--viz-color-bg': '#111', + '--viz-node-color-bg': '#2d2d2d', + '--viz-edge-color': 'white', }, '#root': { height: '100vh', diff --git a/src/useInterpretCanvas.ts b/src/useInterpretCanvas.ts deleted file mode 100644 index 41d3d377..00000000 --- a/src/useInterpretCanvas.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useInterpret } from '@xstate/react'; -import { useEffect } from 'react'; -import { canvasMachine, canvasModel } from './canvasMachine'; -import './Graph'; -import { localCache } from './localCache'; -import { EmbedContext } from './types'; - -export const useInterpretCanvas = ({ - sourceID, - embed, -}: { - sourceID: string | null; - embed?: EmbedContext; -}) => { - const canvasService = useInterpret(canvasMachine, { - actions: { - persistPositionToLocalStorage: (context) => { - // TODO: This can be more elegant when we have system actor - const { zoom, viewbox, embed } = context; - if (!embed?.isEmbedded) { - localCache.savePosition(sourceID, { zoom, viewbox }); - } - }, - }, - context: { - ...canvasModel.initialContext, - embed, - }, - }); - - useEffect(() => { - canvasService.send({ - type: 'SOURCE_CHANGED', - id: sourceID, - }); - }, [sourceID, canvasService]); - - return canvasService; -};