From a3bc88356c970644aea99be6f4cbdb4dfaa2cd21 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 17 Jul 2026 19:44:41 +0700 Subject: [PATCH 1/2] refactor(joint-react): unify Storybook design system, remove embedded docs Rebuilds the @joint/react Storybook around one Showcase frame applied via a global decorator: a shared graph-paper canvas, syntax-highlighted collapsible Show-code, and reusable brand tokens / .jj-* classes so every story reads as one product. - Removes all docs.mdx/story.mdx pages and the step-by-step tutorial (docs now live on the docs site); introduction.mdx becomes a small landing page. - Auto-injects each story's source via ?raw imports instead of hand-written strings; demo code is self-contained. - Makes .jj-paper transparent in the storybook so the dark canvas shows through (removing the per-story PAPER_STYLE workaround). - Points API links at docs.jointjs.com/api-react/{Category}/{Name}. - Drops dead utils (make-story, stories-utils, with-simple-data), unused UI components and 4 unused devDependencies; refreshes a stale knip config. Storybook only - no src changes. --- .../.storybook/decorators/with-showcase.tsx | 80 +++ .../decorators/with-simple-data.tsx | 169 ----- packages/joint-react/.storybook/main.ts | 4 +- packages/joint-react/.storybook/preview.css | 18 + packages/joint-react/.storybook/preview.ts | 27 +- packages/joint-react/.storybook/theme.ts | 53 +- .../joint-react/__mocks__/development-mock.ts | 4 - packages/joint-react/knip.json | 13 +- packages/joint-react/package.json | 7 +- .../components/jsx-to-markup.stories.tsx | 108 --- .../stories/components/jsx-to-markup/code.tsx | 40 ++ .../components/jsx-to-markup/story.tsx | 24 + .../stories/components/svg-text.stories.tsx | 144 ---- .../stories/components/svg-text/code.tsx | 59 ++ .../stories/components/svg-text/story.tsx | 25 + .../demos/automatic-layout-storage/code.tsx | 238 +++---- .../demos/automatic-layout-storage/story.tsx | 26 +- .../stories/demos/collaboration/code.tsx | 50 +- .../stories/demos/collaboration/story.tsx | 25 +- .../stories/demos/flowchart/code.tsx | 13 +- .../stories/demos/flowchart/story.mdx | 39 -- .../stories/demos/flowchart/story.tsx | 24 +- .../stories/demos/introduction-demo/code.tsx | 454 ++++++------ .../stories/demos/introduction-demo/story.tsx | 25 +- .../demos/investment-calculator/code.tsx | 243 +++---- .../demos/investment-calculator/docs.mdx | 47 -- .../demos/investment-calculator/story.tsx | 24 +- .../stories/demos/link-arrows/code.tsx | 76 +- .../stories/demos/link-arrows/story.tsx | 25 +- .../stories/demos/pulsing-port/code.tsx | 132 ++-- .../stories/demos/pulsing-port/story.tsx | 23 +- .../stories/demos/saasflow/code.tsx | 60 +- .../stories/demos/saasflow/story.tsx | 25 +- .../stories/demos/user-flow/code.tsx | 249 +++---- .../stories/demos/user-flow/story.tsx | 24 +- .../examples/auto-size-origin/code.tsx | 132 ++-- .../examples/auto-size-origin/story.tsx | 25 +- .../examples/automatic-layout/code.tsx | 126 ++-- .../examples/automatic-layout/docs.mdx | 61 -- .../examples/automatic-layout/story.tsx | 24 +- .../stories/examples/card/code.tsx | 6 +- .../stories/examples/card/docs.mdx | 59 -- .../stories/examples/card/story.tsx | 26 +- .../stories/examples/cell-actions/code.tsx | 627 +++++------------ .../stories/examples/cell-actions/story.tsx | 29 +- .../stories/examples/cell-json-data/code.tsx | 158 ++--- .../stories/examples/cell-json-data/story.tsx | 25 +- .../examples/collapsible-containers/code.tsx | 70 +- .../examples/collapsible-containers/docs.mdx | 106 --- .../examples/collapsible-containers/story.tsx | 27 +- .../examples/collapsible-subtrees/code.tsx | 40 +- .../examples/collapsible-subtrees/docs.mdx | 67 -- .../examples/collapsible-subtrees/story.tsx | 28 +- .../stories/examples/containers/code.tsx | 92 +-- .../stories/examples/containers/story.tsx | 86 +-- .../stories/examples/css-theme/code.tsx | 80 +-- .../stories/examples/css-theme/story.tsx | 37 +- .../examples/custom-cell-models/code.tsx | 40 +- .../examples/custom-cell-models/story.tsx | 27 +- .../code-create-links-classname.tsx | 15 +- .../custom-link/code-create-links.tsx | 30 +- .../examples/custom-link/code-dia-links.tsx | 41 +- .../stories/examples/custom-link/story.tsx | 89 ++- .../stories/examples/data-defaults/code.tsx | 107 ++- .../stories/examples/data-defaults/story.tsx | 28 +- .../stories/examples/default-element/code.tsx | 106 +-- .../examples/default-element/story.tsx | 33 +- .../stories/examples/default-link/code.tsx | 23 +- .../stories/examples/default-link/story.tsx | 26 +- .../examples/dynamic-interactivity/code.tsx | 127 ++-- .../examples/dynamic-interactivity/story.tsx | 24 +- .../examples/dynamic-status-icons/code.tsx | 148 ++-- .../examples/dynamic-status-icons/docs.mdx | 54 -- .../examples/dynamic-status-icons/story.tsx | 25 +- .../examples/element-controls/code.tsx | 16 +- .../examples/element-controls/docs.mdx | 57 -- .../examples/element-controls/story.tsx | 27 +- .../examples/element-ports-groups/code.tsx | 17 +- .../examples/element-ports-groups/story.tsx | 26 +- .../stories/examples/element-ports/code.tsx | 294 +++----- .../stories/examples/element-ports/story.tsx | 27 +- .../stories/examples/embedding/code.tsx | 168 ++--- .../stories/examples/embedding/story.tsx | 25 +- .../examples/fixed-connection-points/code.tsx | 239 +++---- .../examples/fixed-connection-points/docs.mdx | 122 ---- .../fixed-connection-points/story.tsx | 28 +- .../stories/examples/graph-neighbors/code.tsx | 136 ++-- .../stories/examples/graph-neighbors/docs.mdx | 89 --- .../examples/graph-neighbors/story.tsx | 25 +- .../stories/examples/highlighter/code.tsx | 55 +- .../stories/examples/highlighter/docs.mdx | 76 -- .../stories/examples/highlighter/story.tsx | 25 +- .../stories/examples/intersection/code.tsx | 19 +- .../stories/examples/intersection/docs.mdx | 31 - .../stories/examples/intersection/story.tsx | 24 +- .../stories/examples/jointjs-api/code.tsx | 90 +-- .../stories/examples/jointjs-api/story.tsx | 36 +- .../stories/examples/layers/code.tsx | 230 +++--- .../stories/examples/layers/story.tsx | 107 +-- .../stories/examples/layers/styles.css | 13 + .../stories/examples/link-labels/code.tsx | 39 +- .../stories/examples/link-labels/story.tsx | 26 +- .../examples/link-markers-named/code.tsx | 89 +-- .../examples/link-markers-named/story.tsx | 24 +- .../stories/examples/link-markers/code.tsx | 89 ++- .../stories/examples/link-markers/story.tsx | 24 +- .../stories/examples/link-routing/code.tsx | 289 ++++---- .../stories/examples/link-routing/story.tsx | 28 +- .../stories/examples/link-tools/code.tsx | 72 +- .../stories/examples/link-tools/docs.mdx | 92 --- .../stories/examples/link-tools/story.tsx | 25 +- .../stories/examples/list-node/code.tsx | 177 +++-- .../stories/examples/list-node/docs.mdx | 29 - .../stories/examples/list-node/story.tsx | 26 +- .../examples/markup-selectors-html/code.tsx | 44 +- .../examples/markup-selectors-html/story.tsx | 28 +- .../examples/markup-selectors-html/styles.css | 17 - .../examples/markup-selectors/code.tsx | 139 ++-- .../examples/markup-selectors/story.tsx | 30 +- .../stories/examples/minimap/code.tsx | 84 +-- .../stories/examples/minimap/docs.mdx | 28 - .../stories/examples/minimap/story.tsx | 25 +- .../stories/examples/native-ports/code.tsx | 67 +- .../stories/examples/native-ports/story.tsx | 67 +- .../stories/examples/native-shapes/code.tsx | 111 +-- .../stories/examples/native-shapes/story.tsx | 79 +-- .../examples/portal-selectors/code.tsx | 282 ++++---- .../examples/portal-selectors/docs.mdx | 86 --- .../examples/portal-selectors/story.tsx | 27 +- .../stories/examples/proximity-link/code.tsx | 25 +- .../stories/examples/proximity-link/docs.mdx | 25 - .../stories/examples/proximity-link/story.tsx | 24 +- .../stories/examples/render-link/code.tsx | 82 +-- .../stories/examples/render-link/docs.mdx | 23 - .../stories/examples/render-link/story.tsx | 25 +- .../stories/examples/resizable-node/code.tsx | 82 +-- .../stories/examples/resizable-node/docs.mdx | 33 - .../stories/examples/resizable-node/story.tsx | 25 +- .../stories/examples/rotatable-node/code.tsx | 76 +- .../stories/examples/rotatable-node/docs.mdx | 30 - .../stories/examples/rotatable-node/story.tsx | 24 +- .../examples/shape-animations/code.tsx | 313 +++------ .../examples/shape-animations/docs.mdx | 71 -- .../examples/shape-animations/story.tsx | 28 +- .../stories/examples/stress/code.tsx | 96 +-- .../stories/examples/stress/story.tsx | 26 +- .../stories/examples/svg-node/code.tsx | 64 +- .../stories/examples/svg-node/docs.mdx | 74 -- .../stories/examples/svg-node/story.tsx | 24 +- .../stories/examples/tailwind-theme/code.tsx | 116 ++-- .../stories/examples/tailwind-theme/story.tsx | 37 +- .../stories/examples/theme-editor/code.tsx | 421 ++++------- .../stories/examples/theme-editor/story.tsx | 38 +- .../stories/examples/transaction/code.tsx | 120 ++-- .../stories/examples/transaction/docs.mdx | 66 -- .../stories/examples/transaction/story.tsx | 30 +- .../examples/update/code-add-remove-node.tsx | 124 ++-- .../stories/examples/update/code-color.tsx | 63 +- .../stories/examples/update/code-svg.tsx | 46 +- .../stories/examples/update/code.tsx | 86 +-- .../stories/examples/update/docs.mdx | 119 ---- .../stories/examples/update/story.tsx | 71 +- packages/joint-react/stories/introduction.mdx | 121 +--- packages/joint-react/stories/stories-utils.ts | 6 - .../code-controlled-mode-jotai.tsx | 234 ------- .../code-controlled-mode-peerjs.tsx | 539 --------------- .../code-controlled-mode-redux.tsx | 381 ---------- .../code-controlled-mode-reset-bug.tsx | 84 --- .../code-controlled-mode-reset.tsx | 158 ----- .../code-controlled-mode-zustand.tsx | 246 ------- .../step-by-step/code-controlled-mode.tsx | 290 -------- .../step-by-step/code-html-renderer.tsx | 129 ---- .../tutorials/step-by-step/code-html.tsx | 54 -- .../tutorials/step-by-step/code-svg.tsx | 65 -- .../stories/tutorials/step-by-step/docs.mdx | 654 ------------------ .../stories/tutorials/step-by-step/story.tsx | 57 -- packages/joint-react/stories/ui/badge.tsx | 32 + packages/joint-react/stories/ui/canvas.tsx | 46 ++ packages/joint-react/stories/ui/cn.ts | 15 + .../joint-react/stories/ui/code-block.tsx | 179 +++++ packages/joint-react/stories/ui/demo.css | 190 +++++ packages/joint-react/stories/ui/showcase.tsx | 135 ++++ packages/joint-react/stories/ui/tokens.css | 102 +++ .../stories/utils/get-api-docs-base-url.tsx | 2 +- .../utils/get-api-documentation-link.tsx | 34 +- .../joint-react/stories/utils/make-story.tsx | 136 ---- yarn.lock | 87 +-- 187 files changed, 5383 insertions(+), 10716 deletions(-) create mode 100644 packages/joint-react/.storybook/decorators/with-showcase.tsx delete mode 100644 packages/joint-react/.storybook/decorators/with-simple-data.tsx delete mode 100644 packages/joint-react/stories/components/jsx-to-markup.stories.tsx create mode 100644 packages/joint-react/stories/components/jsx-to-markup/code.tsx create mode 100644 packages/joint-react/stories/components/jsx-to-markup/story.tsx delete mode 100644 packages/joint-react/stories/components/svg-text.stories.tsx create mode 100644 packages/joint-react/stories/components/svg-text/code.tsx create mode 100644 packages/joint-react/stories/components/svg-text/story.tsx delete mode 100644 packages/joint-react/stories/demos/flowchart/story.mdx delete mode 100644 packages/joint-react/stories/demos/investment-calculator/docs.mdx delete mode 100644 packages/joint-react/stories/examples/automatic-layout/docs.mdx delete mode 100644 packages/joint-react/stories/examples/card/docs.mdx delete mode 100644 packages/joint-react/stories/examples/collapsible-containers/docs.mdx delete mode 100644 packages/joint-react/stories/examples/collapsible-subtrees/docs.mdx delete mode 100644 packages/joint-react/stories/examples/dynamic-status-icons/docs.mdx delete mode 100644 packages/joint-react/stories/examples/element-controls/docs.mdx delete mode 100644 packages/joint-react/stories/examples/fixed-connection-points/docs.mdx delete mode 100644 packages/joint-react/stories/examples/graph-neighbors/docs.mdx delete mode 100644 packages/joint-react/stories/examples/highlighter/docs.mdx delete mode 100644 packages/joint-react/stories/examples/intersection/docs.mdx create mode 100644 packages/joint-react/stories/examples/layers/styles.css delete mode 100644 packages/joint-react/stories/examples/link-tools/docs.mdx delete mode 100644 packages/joint-react/stories/examples/list-node/docs.mdx delete mode 100644 packages/joint-react/stories/examples/markup-selectors-html/styles.css delete mode 100644 packages/joint-react/stories/examples/minimap/docs.mdx delete mode 100644 packages/joint-react/stories/examples/portal-selectors/docs.mdx delete mode 100644 packages/joint-react/stories/examples/proximity-link/docs.mdx delete mode 100644 packages/joint-react/stories/examples/render-link/docs.mdx delete mode 100644 packages/joint-react/stories/examples/resizable-node/docs.mdx delete mode 100644 packages/joint-react/stories/examples/rotatable-node/docs.mdx delete mode 100644 packages/joint-react/stories/examples/shape-animations/docs.mdx delete mode 100644 packages/joint-react/stories/examples/svg-node/docs.mdx delete mode 100644 packages/joint-react/stories/examples/transaction/docs.mdx delete mode 100644 packages/joint-react/stories/examples/update/docs.mdx delete mode 100644 packages/joint-react/stories/stories-utils.ts delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-controlled-mode-reset-bug.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-controlled-mode-reset.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-controlled-mode.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-html-renderer.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-html.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/code-svg.tsx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/docs.mdx delete mode 100644 packages/joint-react/stories/tutorials/step-by-step/story.tsx create mode 100644 packages/joint-react/stories/ui/badge.tsx create mode 100644 packages/joint-react/stories/ui/canvas.tsx create mode 100644 packages/joint-react/stories/ui/cn.ts create mode 100644 packages/joint-react/stories/ui/code-block.tsx create mode 100644 packages/joint-react/stories/ui/demo.css create mode 100644 packages/joint-react/stories/ui/showcase.tsx create mode 100644 packages/joint-react/stories/ui/tokens.css delete mode 100644 packages/joint-react/stories/utils/make-story.tsx diff --git a/packages/joint-react/.storybook/decorators/with-showcase.tsx b/packages/joint-react/.storybook/decorators/with-showcase.tsx new file mode 100644 index 0000000000..2bac6e82fe --- /dev/null +++ b/packages/joint-react/.storybook/decorators/with-showcase.tsx @@ -0,0 +1,80 @@ +import type { Decorator } from '@storybook/react-vite'; +import type { ReactNode } from 'react'; +import { Showcase } from '../../stories/ui/showcase'; +import { Badge } from '../../stories/ui/badge'; +import type { CodeFile } from '../../stories/ui/code-block'; + +/** + * Per-story frame configuration, set via `parameters.showcase`. All fields are + * optional; the frame derives sensible defaults from the story's title/name. + * Set `parameters.showcase = false` to opt a story out of the frame entirely. + */ +interface ShowcaseParameters { + /** One-line description. Deep docs live in joint-docs, keep this to a sentence. */ + readonly description?: ReactNode; + /** Auto-injected source, always via a `?raw` import — never a hand-written string. */ + readonly code?: string; + /** Multiple named `?raw` snippets, rendered as tabs. */ + readonly files?: readonly CodeFile[]; + /** Filename label for a single `code` snippet. */ + readonly filename?: string; + /** Link to the matching API reference page. */ + readonly apiUrl?: string; + /** Override the auto-derived title. */ + readonly title?: string; + /** Canvas height in px. Default 420. */ + readonly canvasHeight?: number | string; + /** Render the diagram surface without the dot grid. */ + readonly plainCanvas?: boolean; + /** Skip the canvas surface — for full-app demos that own their background. */ + readonly bare?: boolean; +} + +const EYEBROWS: Record = { + Examples: 'Example', + Demos: 'Demo', + Components: 'Component', + Tutorials: 'Tutorial', + Utils: 'Utility', + Hooks: 'Hook', +}; + +function eyebrowFor(group: string): string { + return EYEBROWS[group] ?? group.replace(/s$/, ''); +} + +/** + * Wraps every story in the unified {@link Showcase} frame. Title and category + * eyebrow are derived from the Storybook `title`; the source snippet and + * description come from `parameters.showcase`. + */ +export const withShowcase: Decorator = (Story, context) => { + const showcase = context.parameters?.showcase as ShowcaseParameters | false | undefined; + if (showcase === false) { + return ; + } + + const segments = context.title.split('/'); + const group = segments[0] ?? ''; + const base = segments.at(-1) ?? context.title; + const storyName = context.name; + const isVariant = storyName !== 'Default' && storyName !== base; + + return ( + {storyName} : undefined} + description={showcase?.description} + apiUrl={showcase?.apiUrl} + code={showcase?.code} + files={showcase?.files} + filename={showcase?.filename} + canvasHeight={showcase?.canvasHeight} + plainCanvas={showcase?.plainCanvas} + bare={showcase?.bare} + > + + + ); +}; diff --git a/packages/joint-react/.storybook/decorators/with-simple-data.tsx b/packages/joint-react/.storybook/decorators/with-simple-data.tsx deleted file mode 100644 index 3398831601..0000000000 --- a/packages/joint-react/.storybook/decorators/with-simple-data.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ - -// @ts-expect-error do not provide typings. -import JsonViewer from '@andypf/json-viewer/dist/esm/react/JsonViewer'; -import { useCallback, useRef, type HTMLProps, type JSX, type PropsWithChildren } from 'react'; -import { - GraphProvider, - selectElementSize, - useCell, - useCellId, - useMeasureElement, - type CellRecord, - type ElementRecord, -} from '@joint/react'; -import { PAPER_CLASSNAME, PRIMARY } from '../theme'; -import type { PartialStoryFn, StoryContext } from 'storybook/internal/types'; -import { Paper } from '../../src/components/paper/paper'; - -export type StoryFunction = PartialStoryFn; -export type StoryCtx = StoryContext; - -type TestElementData = { - label: string; - color: string; - hoverColor: string; -}; - -export const testCells: ReadonlyArray> = [ - { - id: '1', - type: 'element', - data: { - label: 'Node 1', - color: PRIMARY, - hoverColor: 'red', - }, - position: { x: 100, y: 20 }, - size: { width: 150, height: 50 }, - angle: 0, - }, - { - id: '2', - type: 'element', - data: { - label: 'Node 2', - color: PRIMARY, - hoverColor: 'blue', - }, - position: { x: 200, y: 250 }, - size: { width: 150, height: 50 }, - angle: 0, - }, - { - id: 'l-1', - type: 'link', - source: { id: '1' }, - target: { id: '2' }, - style: { color: PRIMARY }, - }, -]; - -export type SimpleElement = ElementRecord; - -export function SimpleGraphProviderDecorator({ children }: Readonly) { - return {children}; -} - -export function SimpleGraphDecorator(Story: StoryFunction, { args }: StoryCtx) { - return ( - - - - ); -} - -export function RenderItemDecorator( - properties: Readonly<{ - renderElement: () => JSX.Element; - renderLink?: () => JSX.Element; - cells?: readonly CellRecord[]; - }> -) { - return ( -
- - - -
- ); -} - -function RenderSimpleRectElement(data: Readonly<{ color: string }>) { - const size = useCell(selectElementSize); - return ; -} - -export function RenderGraphViewWithChildren(properties: Readonly<{ children: JSX.Element }>) { - return ( -
- - - {properties.children} - - -
- ); -} - -export function SimpleRenderItemDecorator(Story: StoryFunction, { args }: StoryCtx) { - const component = useCallback(() => , [Story, args]); - return ; -} - -export function SimpleRenderLinkDecorator(Story: StoryFunction, { args }: StoryCtx) { - const component = useCallback(() => , [Story, args]); - return ( - { - // eslint-disable-next-line react-hooks/rules-of-hooks - const id = useCellId(); - return {id}; - }} - /> - ); -} - -export function HTMLNode(props: PropsWithChildren>) { - const divRef = useRef(null); - const { width, height } = useMeasureElement(divRef); - - return ( - -
- - ); -} - -export function ShowJson(props: Readonly<{ readonly data: string; showCopy?: boolean }>) { - return ( - - ); -} - -export function DataRenderer({ data, name }: Readonly<{ data: unknown; name: string }>) { - return ( -
-

{name}:

- -
- ); -} diff --git a/packages/joint-react/.storybook/main.ts b/packages/joint-react/.storybook/main.ts index e32fd43c09..242032289f 100644 --- a/packages/joint-react/.storybook/main.ts +++ b/packages/joint-react/.storybook/main.ts @@ -10,10 +10,10 @@ const __dirname = path.dirname(__filename); configureSort({ storyOrder: { - tutorials: null, examples: null, + demos: null, components: null, - hooks: null, + utils: null, '**': { default: null }, }, }); diff --git a/packages/joint-react/.storybook/preview.css b/packages/joint-react/.storybook/preview.css index 1b5e016257..ddec302d1d 100644 --- a/packages/joint-react/.storybook/preview.css +++ b/packages/joint-react/.storybook/preview.css @@ -1,7 +1,25 @@ @import 'tailwindcss'; +@import '../stories/ui/tokens.css'; +@import '../stories/ui/demo.css'; @import '../src/css/styles.css' layer(components); /* Custom variants for the Tailwind theme demo */ @custom-variant forest (&:where(.forest, .forest *)); @custom-variant ocean (&:where(.ocean, .ocean *)); @custom-variant sunset (&:where(.sunset, .sunset *)); + +/* Storybook canvas root — the deep app backdrop behind every Showcase frame. */ +.sb-show-main, +body { + background-color: var(--color-app); + color: var(--color-ink); + font-family: var(--font-display); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* A single, very subtle brand glow anchored top-center. Not a glowing-accent + cliché — it just keeps the flat dark backdrop from feeling dead. */ +.sb-show-main { + background-image: radial-gradient(120% 60% at 50% -10%, oklch(0.5 0.14 20 / 0.06), transparent 60%); +} diff --git a/packages/joint-react/.storybook/preview.ts b/packages/joint-react/.storybook/preview.ts index 3808d56c07..20bd9f21ff 100644 --- a/packages/joint-react/.storybook/preview.ts +++ b/packages/joint-react/.storybook/preview.ts @@ -1,26 +1,22 @@ import type { Preview } from '@storybook/react-vite'; import { theme } from './theme'; import { withStrictMode } from './decorators/with-strict-mode'; +import { withShowcase } from './decorators/with-showcase'; import './preview.css'; -export const decorators = [withStrictMode]; +// Showcase is the outermost frame; StrictMode wraps the live story inside it. +export const decorators = [withShowcase, withStrictMode]; const preview: Preview = { parameters: { - layout: 'padded', + // The Showcase frame owns all spacing and the app background. + layout: 'fullscreen', docs: { theme, }, - backgrounds: { - options: { - dark: { name: 'Dark', value: theme.appBg }, - }, - }, - parameters: { - options: { - // @ts-expect-error its storybook multilevel sort - storySort: (a, b) => globalThis['storybook-multilevel-sort:storySort'](a, b), - }, + options: { + // @ts-expect-error injected by storybook-multilevel-sort + storySort: (a, b) => globalThis['storybook-multilevel-sort:storySort'](a, b), }, controls: { matchers: { @@ -30,14 +26,7 @@ const preview: Preview = { }, }, - tags: ['autodocs'], decorators, - - initialGlobals: { - backgrounds: { - value: 'dark', - }, - }, }; export default preview; diff --git a/packages/joint-react/.storybook/theme.ts b/packages/joint-react/.storybook/theme.ts index 43341d592e..b09c7f2ada 100644 --- a/packages/joint-react/.storybook/theme.ts +++ b/packages/joint-react/.storybook/theme.ts @@ -1,32 +1,41 @@ import { create } from 'storybook/theming'; -export const PRIMARY = '#ED2637'; -export const BG = '#131E29'; -export const SECONDARY = '#FF9505'; -export const LIGHT = '#DDE6ED'; -export const TEXT = '#DDE6ED'; + +// Brands the Storybook chrome (sidebar, toolbar). Diagram colors live in +// `stories/ui/tokens.css`; individual demos keep their own local color consts. +const BRAND = '#ED2637'; +const TEXT = '#DDE6ED'; +const APP_BG = '#0C141C'; +const APP_CONTENT = '#101B25'; + export const theme = create({ base: 'dark', - brandTitle: 'JointJS react', - fontBase: 'Ppfraktionsans, sans-serif', + brandTitle: 'JointJS React', + fontBase: 'Ppfraktionsans, ui-sans-serif, system-ui, sans-serif', + fontCode: 'ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace', brandUrl: 'https://www.jointjs.com/', brandImage: 'https://cdn.prod.website-files.com/63061d4ee85b5a18644f221c/633045c1d726c7116dcbe582_JJS_logo.svg', brandTarget: '_self', - colorPrimary: PRIMARY, - appBg: BG, - appContentBg: BG, - textColor: TEXT, - colorSecondary: PRIMARY, - barTextColor: TEXT, - barSelectedColor: PRIMARY, - barHoverColor: PRIMARY, - barBg: BG, -}); -export const BUTTON_CLASSNAME = - 'bg-blue-500 cursor-pointer hover:bg-blue-700 text-white font-bold py-2 px-4 rounded m-2 text-sm'; + colorPrimary: BRAND, + colorSecondary: BRAND, -export const PAPER_CLASSNAME = - 'border-1 border-gray-300 rounded-lg shadow-md overflow-hidden p-4 mr-2 h-[400px] w-full'; + appBg: APP_BG, + appContentBg: APP_CONTENT, + appPreviewBg: APP_BG, + appBorderColor: 'rgba(221,230,237,0.10)', + appBorderRadius: 10, -export const PAPER_STYLE = { backgroundColor: BG } as const; + textColor: TEXT, + textMutedColor: '#93A4B3', + + barBg: APP_BG, + barTextColor: '#93A4B3', + barSelectedColor: BRAND, + barHoverColor: BRAND, + + inputBg: APP_CONTENT, + inputBorder: 'rgba(221,230,237,0.14)', + inputTextColor: TEXT, + inputBorderRadius: 8, +}); diff --git a/packages/joint-react/__mocks__/development-mock.ts b/packages/joint-react/__mocks__/development-mock.ts index 08c434f1e8..fd7bc99346 100644 --- a/packages/joint-react/__mocks__/development-mock.ts +++ b/packages/joint-react/__mocks__/development-mock.ts @@ -1,7 +1,3 @@ -jest.mock('@andypf/json-viewer/dist/esm/react/JsonViewer', () => - jest.fn((properties: unknown) => JSON.stringify(properties, undefined, 2)) -); - jest.mock('../stories/utils/get-api-docs-base-url', () => ({ getApiDocsBaseUrl: jest.fn(() => 'https://www.jointjs.com/'), })); diff --git a/packages/joint-react/knip.json b/packages/joint-react/knip.json index e2016b1f23..9b9c2654cb 100644 --- a/packages/joint-react/knip.json +++ b/packages/joint-react/knip.json @@ -1,15 +1,20 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/index.ts!", "src/internal.ts!", "stories/stories-utils.ts!"], - "ignore": ["**/*.css", ".storybook/**", "stories/utils/get-api-documentation-link.tsx"], + "entry": [ + "src/internal.ts!", + ".storybook/{main,manager,preview}.ts", + "stories/**/story.tsx", + "stories/**/*.mdx" + ], + "ignore": ["**/*.css"], "ignoreDependencies": [ "react-scan", "@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript", "babel-plugin-react-compiler", - "react18", - "react18-dom" + "react18-dom", + "@storybook/react" ], "ignoreExportsUsedInFile": false } diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index d4c8d9ac57..d2ca6ff764 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -82,7 +82,6 @@ "react-dom": ">=18" }, "devDependencies": { - "@andypf/json-viewer": "^2.1.10", "@babel/preset-env": "^7.29.7", "@babel/preset-react": "^7.29.7", "@babel/preset-typescript": "^7.29.7", @@ -114,10 +113,10 @@ "eslint-plugin-storybook": "^10.4.2", "jest": "30.1.2", "jest-environment-jsdom": "30.1.2", - "jotai": "^2.15.2", "knip": "6.21.0", "peerjs": "^1.5.5", "prettier": "3.3.3", + "prism-react-renderer": "^2.4.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-redux": "^9.2.0", @@ -125,7 +124,6 @@ "react18": "npm:react@18.3.1", "react18-dom": "npm:react-dom@18.3.1", "redux": "^5.0.1", - "redux-undo": "1.1.0", "rollup": "^4.50.0", "rollup-plugin-banner2": "^1.2.2", "rollup-plugin-dts": "^6.4.1", @@ -139,8 +137,7 @@ "typedoc-plugin-markdown": "^4.6.4", "typescript": "^5.9.2", "vite": "^8", - "vite-plugin-md": "0.22.5", - "zustand": "^5.0.9" + "vite-plugin-md": "0.22.5" }, "resolutions": { "react": "19.1.1", diff --git a/packages/joint-react/stories/components/jsx-to-markup.stories.tsx b/packages/joint-react/stories/components/jsx-to-markup.stories.tsx deleted file mode 100644 index 44c5219d41..0000000000 --- a/packages/joint-react/stories/components/jsx-to-markup.stories.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { useMemo } from 'react'; -import { dia } from '@joint/core'; -import '../examples/index.css'; -import { GraphProvider, jsx, Paper } from '@joint/react'; -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { SimpleGraphDecorator } from 'storybook-config/decorators/with-simple-data'; -import { getAPILink } from '../utils/get-api-documentation-link'; -import { makeRootDocumentation } from '../utils/make-story'; - -const API_URL = getAPILink('jsx'); - -// Define a custom element using JointJS and provide markup using the jsx utility -const CustomShape = dia.Element.define( - 'CustomShape', - { - attrs: { - body: { - fill: PRIMARY, - stroke: '#333', - strokeWidth: 2, - }, - label: { - text: 'JSX Markup', - fill: '#fff', - fontSize: 14, - fontWeight: 'bold', - }, - }, - size: { width: 120, height: 50 }, - }, - { - markup: jsx( - - - - - ), - } -); - -function createGraph(): dia.Graph { - const graph = new dia.Graph({}, { cellNamespace: { CustomShape } }); - graph.addCell( - new CustomShape({ - position: { x: 80, y: 80 }, - }) - ); - return graph; -} - -function App() { - const graph = useMemo(() => createGraph(), []); - return ( - - - - ); -} - -export type Story = StoryObj; - -const meta: Meta = { - title: 'Utils/JSX', - component: App, - decorators: [SimpleGraphDecorator], - tags: ['utils'], - parameters: makeRootDocumentation({ - apiURL: API_URL, - code: `import { dia } from '@joint/core'; -import { jsx } from '@joint/react'; - -const CustomShape = dia.Element.define( - 'CustomShape', - { - attrs: { - body: { fill: '#007bff' }, - label: { text: 'JSX Markup' }, - }, - size: { width: 120, height: 50 }, - }, - { - markup: jsx( - - - - - ), - } -);`, - description: ` -This story demonstrates how to use the \`jsx\` utility to define JointJS markup for a custom element. - `, - }), -}; - -export default meta; - -export const Default: Story = { - args: {}, -}; diff --git a/packages/joint-react/stories/components/jsx-to-markup/code.tsx b/packages/joint-react/stories/components/jsx-to-markup/code.tsx new file mode 100644 index 0000000000..f841a7d404 --- /dev/null +++ b/packages/joint-react/stories/components/jsx-to-markup/code.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { dia } from '@joint/core'; +import { GraphProvider, jsx, Paper } from '@joint/react'; + +const PRIMARY = '#ED2637'; + +// Define a JointJS element whose SVG markup is written as JSX via the `jsx` util. +const CustomShape = dia.Element.define( + 'CustomShape', + { + attrs: { + body: { fill: PRIMARY, stroke: '#0c141c', strokeWidth: 2 }, + label: { text: 'JSX Markup', fill: '#fff', fontSize: 14, fontWeight: 'bold' }, + }, + size: { width: 140, height: 56 }, + }, + { + markup: jsx( + + + + + ), + } +); + +function createGraph(): dia.Graph { + const graph = new dia.Graph({}, { cellNamespace: { CustomShape } }); + graph.addCell(new CustomShape({ position: { x: 70, y: 70 } })); + return graph; +} + +export default function App() { + const graph = useMemo(() => createGraph(), []); + return ( + + + + ); +} diff --git a/packages/joint-react/stories/components/jsx-to-markup/story.tsx b/packages/joint-react/stories/components/jsx-to-markup/story.tsx new file mode 100644 index 0000000000..807b2da2a3 --- /dev/null +++ b/packages/joint-react/stories/components/jsx-to-markup/story.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { getAPILink } from '../../utils/get-api-documentation-link'; +import Code from './code'; +import codeRaw from './code?raw'; + +const meta = { + title: 'Utils/JSX', + component: Code, + tags: ['utils'], + parameters: { + showcase: { + description: 'Write a JointJS element’s SVG markup as JSX using the jsx utility.', + apiUrl: getAPILink('jsx'), + canvasHeight: 260, + code: codeRaw, + }, + }, +} satisfies Meta; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/joint-react/stories/components/svg-text.stories.tsx b/packages/joint-react/stories/components/svg-text.stories.tsx deleted file mode 100644 index 9b67279a69..0000000000 --- a/packages/joint-react/stories/components/svg-text.stories.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { SimpleRenderItemDecorator } from 'storybook-config/decorators/with-simple-data'; -import { SVGText, useMeasureElement } from '@joint/react'; -import { PRIMARY } from 'storybook-config/theme'; -import { getAPILink } from '../utils/get-api-documentation-link'; -import { makeRootDocumentation, makeStory } from '../utils/make-story'; - -const API_URL = getAPILink('SVGText', 'variables'); -export type Story = StoryObj; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function SVGDecorator(Story: any) { - const gRef = React.useRef(null); - const { width, height } = useMeasureElement(gRef, { - transform: ({ x, y, width: measuredWidth, height: measuredHeight }) => ({ - x: x - PADDING, - y: y - PADDING, - width: measuredWidth + PADDING * 2, - height: measuredHeight + PADDING * 2, - }), - }); - - const PADDING = 10; - return ( - <> - - - - - - ); -} - -const meta: Meta = { - title: 'Components/SVGText', - component: SVGText, - decorators: [SVGDecorator, SimpleRenderItemDecorator], - tags: ['component'], - parameters: makeRootDocumentation({ - apiURL: API_URL, - description: ` -The **SVGText** component renders SVG text with automatic sizing and wrapping capabilities. It's designed to work seamlessly with \`useMeasureElement\` hook for dynamic text content. - -**Key Features:** -- Renders SVG text elements -- Supports automatic text wrapping -- Integrates with \`useMeasureElement\` hook for dynamic sizing -- Supports all standard SVG text properties - `, - usage: ` -\`\`\`tsx -import { SVGText, useMeasureElement } from '@joint/react'; -import { useRef } from 'react'; - -function RenderElement() { - const gRef = useRef(null); - const { width, height } = useMeasureElement(gRef); - return ( - <> - - - - Your text content here - - - - ); -} -\`\`\` - `, - props: ` -- **children**: Text content to render -- **fill**: Text color -- **width**: Maximum width before wrapping -- **textWrap**: Enable automatic text wrapping -- **fontSize**: Text size (default: 14) -- And other standard SVG text properties - `, - code: `import { SVGText, useMeasureElement } from '@joint/react' -import { useRef } from 'react'; - -const gRef = useRef(null); -useMeasureElement(gRef); - - - - Hello world - - - `, - }), -}; - -export default meta; - -export const Default = makeStory({ - args: { - children: 'Hello world', - fill: 'white', - width: 19, - }, - apiURL: API_URL, - name: 'Measured div with exact size', - description: 'Div with exact size.', -}); - -export const WithBreakText = makeStory({ - args: { - children: 'Hello world Hello world', - fill: 'white', - width: 100, - textWrap: true, - }, - apiURL: API_URL, - name: 'Measured div with exact size', - description: 'Div with exact size.', -}); - -export const WithBreakTextWithoutDefinedWith = makeStory({ - args: { - children: 'Hello world Hello world', - fill: 'white', - textWrap: true, - }, - apiURL: API_URL, - name: 'Measured div with exact size', - description: 'Div with exact size.', -}); - -export const WithEllipsis = makeStory({ - args: { - children: 'Hello world Hello world Hello world', - fill: 'white', - width: 100, - textWrap: { - ellipsis: true, - maxLineCount: 1, - }, - }, - apiURL: API_URL, - name: 'Measured div with exact size', - description: 'Div with exact size.', -}); diff --git a/packages/joint-react/stories/components/svg-text/code.tsx b/packages/joint-react/stories/components/svg-text/code.tsx new file mode 100644 index 0000000000..edd85a061e --- /dev/null +++ b/packages/joint-react/stories/components/svg-text/code.tsx @@ -0,0 +1,59 @@ +import { useRef } from 'react'; +import { + GraphProvider, + Paper, + SVGText, + useMeasureElement, + type CellRecord, + type RenderElement, +} from '@joint/react'; + +const PRIMARY = '#ED2637'; +const PADDING = 12; + +type NodeData = { label: string; ellipsis?: boolean }; + +const initialCells: ReadonlyArray> = [ + { id: '1', type: 'element', data: { label: 'Auto-measured SVG text that wraps onto multiple lines.' }, position: { x: 40, y: 40 } }, + { id: '2', type: 'element', data: { label: 'This label is clamped to a single line with an ellipsis.', ellipsis: true }, position: { x: 40, y: 170 } }, +]; + +const WRAP_WIDTH = 180; +const ELLIPSIS_WRAP = { ellipsis: true, maxLineCount: 1 } as const; + +function TextNode({ label, ellipsis }: Readonly) { + const groupRef = useRef(null); + const { width, height } = useMeasureElement(groupRef, { + transform: ({ x, y, width: measuredWidth, height: measuredHeight }) => ({ + x: x - PADDING, + y: y - PADDING, + width: measuredWidth + PADDING * 2, + height: measuredHeight + PADDING * 2, + }), + }); + + return ( + <> + + + + {label} + + + + ); +} + +const renderElement: RenderElement = (data) => ; + +export default function App() { + return ( + + + + ); +} diff --git a/packages/joint-react/stories/components/svg-text/story.tsx b/packages/joint-react/stories/components/svg-text/story.tsx new file mode 100644 index 0000000000..b674cee34c --- /dev/null +++ b/packages/joint-react/stories/components/svg-text/story.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { getAPILink } from '../../utils/get-api-documentation-link'; +import Code from './code'; +import codeRaw from './code?raw'; + +const meta = { + title: 'Components/SVGText', + component: Code, + tags: ['component'], + parameters: { + showcase: { + description: + 'Render SVG text that measures itself and wraps or truncates to a width, sizing the node around it.', + apiUrl: getAPILink('SVGText'), + canvasHeight: 320, + code: codeRaw, + }, + }, +} satisfies Meta; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/joint-react/stories/demos/automatic-layout-storage/code.tsx b/packages/joint-react/stories/demos/automatic-layout-storage/code.tsx index 06469b85fb..af6f8331cc 100644 --- a/packages/joint-react/stories/demos/automatic-layout-storage/code.tsx +++ b/packages/joint-react/stories/demos/automatic-layout-storage/code.tsx @@ -1,7 +1,3 @@ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -/* eslint-disable sonarjs/pseudo-random */ -// We have pre-loaded tailwind css import { GraphProvider, Paper, @@ -10,26 +6,26 @@ import { useGraph, useCells, useOnElementsMeasured, + linkRoutingOrthogonal, type CellRecord, type Computed, type ElementRecord, - linkRoutingOrthogonal, } from '@joint/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -// ───────────────────────────────────────────────────────────────────────────── -// Concept -// -// We persist a flat array of minimal cells — element `data` plus link -// source/target ids. Everything else is automatic: -// -// - size → measured by from the rendered DOM -// - position → recomputed by a tree layout each time nodes are measured -// - styles → declared once in code, not persisted -// -// Click any node to edit it inline (uses `useGraph().setCell`). -// "Save .json" downloads a tiny JSON file. "Load .json" reads one back in. -// ───────────────────────────────────────────────────────────────────────────── +import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode, Ref, SyntheticEvent } from 'react'; + +/* + * Automatic layout & storage + * + * Persist a flat array of minimal cells — element `data` plus link + * source/target ids. Everything else is derived at runtime: + * - size → measured from the rendered DOM by + * - position → recomputed by a tree layout whenever elements are measured + * - styles → declared in code, never persisted + * + * Click a card to edit it inline. "Save .json" downloads the snapshot; + * "Load .json" reads one back and remounts the graph. + */ interface NodeData { readonly title: string; @@ -69,21 +65,31 @@ const SEED: Snapshot = [ { id: 'l7', type: 'link', source: 'n7', target: 'n8' }, ]; -// ───────────────────────────────────────────────────────────────────────────── -// Memo selector: snapshot → unified readonly CellRecord[] array the GraphProvider expects. -// Note that we never put position or size here — those come from measurement -// and the layout pass at runtime. -// ───────────────────────────────────────────────────────────────────────────── +const LINK_STROKE_COLOR = '#8697A6'; +// Links carry style, not geometry — declared once here, never persisted. const LINK_ATTRS = { line: { - stroke: '#1c2434', + stroke: LINK_STROKE_COLOR, strokeWidth: 1.5, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, }, }; +// Orthogonal routing is static, so build it once outside render. +const LINK_ROUTING = linkRoutingOrthogonal({ + sourceOffset: 6, + targetOffset: 6, + margin: 18, + mode: 'bottom-top', +}); + +/** + * Expand the minimal snapshot into the `CellRecord[]` the `GraphProvider` + * expects. Position and size are intentionally omitted — they come from + * measurement and the layout pass at runtime. + */ function toCells(snapshot: Snapshot): ReadonlyArray> { return snapshot.map((cell) => cell.type === 'element' @@ -98,9 +104,7 @@ function toCells(snapshot: Snapshot): ReadonlyArray> { ); } -// ───────────────────────────────────────────────────────────────────────────── -// File save / load (simple .json round-trip) -// ───────────────────────────────────────────────────────────────────────────── +// ── File save / load (a plain .json round-trip) ────────────────────────────── function saveSnapshotToFile(snapshot: Snapshot, filename: string): void { const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json' }); @@ -119,14 +123,14 @@ async function loadSnapshotFromFile(file: File): Promise { return parsed; } -// ───────────────────────────────────────────────────────────────────────────── -// Tree layout — DFS assigns each leaf the next column, each parent the -// midpoint of its children. Reruns every time HTMLHost reports new sizes. -// ───────────────────────────────────────────────────────────────────────────── +// ── Tree layout ────────────────────────────────────────────────────────────── +// A DFS gives each leaf the next column and each parent the midpoint of its +// children. It reruns every time reports new sizes. const COLUMN_WIDTH = 230; const ROW_GAP = 50; const PADDING = 40; + function LayoutRunner() { const { graph } = useGraph>(); @@ -193,10 +197,9 @@ function LayoutRunner() { return null; } -// ───────────────────────────────────────────────────────────────────────────── -// Renderer — a card with click-to-edit fields. -// Uses `useGraph().setCell` from inside the graph context to update its own data. -// ───────────────────────────────────────────────────────────────────────────── +// ── Node card ──────────────────────────────────────────────────────────────── +// A card with click-to-edit fields. Uses `useGraph().setCell` from inside the +// graph context to update its own data. function NodeCard({ title, owner }: Readonly) { const id = useCellId(); @@ -212,7 +215,7 @@ function NodeCard({ title, owner }: Readonly) { // Exit edit mode when the user clicks/taps anywhere outside the card. useEffect(() => { if (!isEditing) return; - const handler = (event: MouseEvent) => { + const handler = (event: Event) => { if (editorRef.current?.contains(event.target as Node)) return; setIsEditing(false); }; @@ -220,13 +223,13 @@ function NodeCard({ title, owner }: Readonly) { return () => document.removeEventListener('mousedown', handler); }, [isEditing]); - // Inputs need to swallow the pointer/mouse events so JointJS doesn't try - // to start a drag on the cell while the user is interacting with them. - const swallowEditorEvent = useCallback((event: React.SyntheticEvent) => { + // Inputs must swallow the pointer/mouse events so JointJS doesn't start a + // drag on the cell while the user is interacting with them. + const swallowEditorEvent = useCallback((event: SyntheticEvent) => { event.stopPropagation(); }, []); - const enterEdit = useCallback((event: React.MouseEvent) => { + const enterEdit = useCallback((event: MouseEvent) => { event.stopPropagation(); setIsEditing(true); }, []); @@ -240,7 +243,7 @@ function NodeCard({ title, owner }: Readonly) { [setCell, id, title] ); - const exitOnEnter = useCallback((event: React.KeyboardEvent) => { + const exitOnEnter = useCallback((event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === 'Escape') { event.preventDefault(); setIsEditing(false); @@ -249,10 +252,10 @@ function NodeCard({ title, owner }: Readonly) { return ( {isEditing ? ( @@ -280,43 +283,45 @@ function NodeCard({ title, owner }: Readonly) { type="button" onClick={enterEdit} onPointerDown={swallowEditorEvent} - className="flex items-center justify-between w-full p-0 m-0 font-serif text-base font-semibold text-[#1c2434] text-left bg-transparent border-0 cursor-text" + className="flex items-center justify-between w-full p-0 m-0 font-serif text-base font-semibold text-ink text-left bg-transparent border-0 cursor-text" > {title} -
Owner · {owner}
+
Owner · {owner}
)}
); } -function FieldLabel({ children }: Readonly<{ children: React.ReactNode }>) { +function FieldLabel({ children }: Readonly<{ children: ReactNode }>) { return ( -
- {children} -
+
{children}
); } interface InlineInputProps { readonly value: string; readonly onChange: (next: string) => void; - readonly onKeyDown: (event: React.KeyboardEvent) => void; + readonly onKeyDown: (event: KeyboardEvent) => void; readonly font: 'serif' | 'sans'; - readonly ref?: React.Ref; + readonly ref?: Ref; } function InlineInput({ value, onChange, onKeyDown, font, ref }: Readonly) { + const handleChange = useCallback( + (event: ChangeEvent) => onChange(event.target.value), + [onChange] + ); return ( onChange(event.target.value)} + onChange={handleChange} onKeyDown={onKeyDown} - className={`w-full px-0 py-1 m-0 bg-transparent border-0 border-b border-[rgba(28,36,52,0.25)] outline-none text-[#1c2434] ${ + className={`w-full px-0 py-1 m-0 bg-transparent border-0 border-b border-hairline-strong outline-none text-ink ${ font === 'serif' ? 'font-serif text-base font-semibold' : 'font-sans text-xs font-medium' }`} /> @@ -342,10 +347,9 @@ function PencilGlyph() { ); } -// ───────────────────────────────────────────────────────────────────────────── -// Inspector — reads live graph state via hooks and projects it back to the -// minimal Snapshot shape the user actually persists. -// ───────────────────────────────────────────────────────────────────────────── +// ── Inspector ──────────────────────────────────────────────────────────────── +// Reads live graph state via hooks and projects it back to the minimal +// Snapshot shape the user actually persists. function useLiveSnapshot(): Snapshot { const cells = useCells>>(); @@ -381,41 +385,39 @@ function Inspector({ snapshot }: Readonly<{ snapshot: Snapshot }>) { const linkCount = snapshot.filter((cell) => cell.type === 'link').length; return ( -