Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion packages/react/src/components/NodeRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ interface NodeRendererProps {
activeMatchNodeId?: string
}

/**
* Mount fade-in duration for nodes, kept in sync with the transition string
* used in `MemoizedNode`'s mount effect. The fade-clear timer fires at
* `MOUNT_FADE_MS + 16` (one frame of headroom).
*/
const MOUNT_FADE_MS = 200

/**
* Renders all nodes, dispatching to the appropriate type-specific component.
* Groups are rendered first (lower z-index), then other nodes in array order.
Expand Down Expand Up @@ -104,6 +111,7 @@ const MemoizedNode = React.memo(function MemoizedNode({
}) {
const gRef = useRef<SVGGElement>(null)
const mountedRef = useRef(false)
const fadeClearTimerRef = useRef<number | null>(null)

useEffect(() => {
const g = gRef.current
Expand All @@ -114,18 +122,46 @@ const MemoizedNode = React.memo(function MemoizedNode({
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
g.getBoundingClientRect()
requestAnimationFrame(() => {
g.style.transition = 'opacity 200ms cubic-bezier(0.22, 1, 0.36, 1)'
g.style.transition = `opacity ${MOUNT_FADE_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
g.style.opacity = isDimmed ? '0.15' : '1'
// Clear the inline styles once the mount fade finishes so the SVG
// presentation attribute (`opacity={isDimmed ? 0.15 : 1}`) governs
// every subsequent dim/undim flip. Inline style wins the CSS cascade
// over the attribute, so leaving it in place would freeze whatever
// opacity the node was mounted with — a node already on screen could
// never visually dim or undim. Mirrors the clear-after-fade pattern
// in Viewport's `triggerFade`.
fadeClearTimerRef.current = window.setTimeout(() => {
g.style.transition = ''
g.style.opacity = ''
fadeClearTimerRef.current = null
}, MOUNT_FADE_MS + 16)
})
}, []) // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
const g = gRef.current
if (!g || !isExiting) return
// Cancel a pending mount-fade clear so a late inline-style write can't
// interrupt the in-flight exit fade.
if (fadeClearTimerRef.current !== null) {
clearTimeout(fadeClearTimerRef.current)
fadeClearTimerRef.current = null
}
g.style.transition = 'opacity 150ms ease-in'
g.style.opacity = '0'
}, [isExiting])

// Unmount: cancel the pending clear so it never fires on a detached node.
useEffect(() => {
return () => {
if (fadeClearTimerRef.current !== null) {
clearTimeout(fadeClearTimerRef.current)
fadeClearTimerRef.current = null
}
}
}, [])

const slots = getCategorySlots(node, theme)
const reservations = computeReflowReservations(node, theme, slots)
const explicitCorner =
Expand Down
86 changes: 84 additions & 2 deletions packages/react/src/components/SystemCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ import { CollaboratorsOverlay } from './CollaboratorsOverlay.js'
import { ExportButton, type ExportButtonRenderProps } from './ExportButton.js'
import { exportAsJSON, parseCanvasFile, exportAsPNG, copyAsImage, exportAsSVG } from '../export/index.js'

/**
* Union an external emphasis set with the internal search-derived set.
*
* Returns `internal` unchanged (same reference — zero allocation, no
* re-render churn) when `external` is undefined or empty; otherwise a new
* Set containing every id from both inputs.
*
* Exported from this module for unit tests only — it is deliberately NOT
* part of the published package API (`packages/core`'s index.ts): a generic
* set-union is not search logic, and core stays untouched by emphasis
* merging.
*/
export function unionIdSets(
internal?: Set<string>,
external?: Set<string>
): Set<string> | undefined {
if (!external || external.size === 0) return internal
if (!internal || internal.size === 0) return new Set(external)
const merged = new Set(internal)
for (const id of external) merged.add(id)
return merged
}

export interface SystemCanvasProps {
/** Canvas data to render */
canvas: CanvasData
Expand Down Expand Up @@ -458,6 +481,48 @@ export interface SystemCanvasProps {
/** Show a bird's-eye minimap in the bottom-left corner. Default false. */
showMinimap?: boolean

// --- External emphasis (render-only) ---
/**
* Node ids that should render dimmed, driven by the host application's own
* state (an external search, filter, or agent-driven focus).
*
* **Union-merge semantics:** these ids merge (union) with the dim set the
* library derives from its internal Cmd+F search, so both sources can be
* active simultaneously and search keeps working unchanged.
*
* **Render-only:** dimming never moves nodes, affects layout or auto-fit,
* or changes selection, click, drag, or editing behaviour. It does
* propagate visually to edges connected to dimmed nodes (dotted, faded),
* matching internal search behaviour.
*
* Omitting the prop or passing an empty set is a no-op. Prefer a stable
* `Set` identity across renders as a perf courtesy — an unstable set
* merely recomputes the merge memo each render, which is tolerable
* (Viewport is not memoized and already re-renders with SystemCanvas).
*/
dimmedNodeIds?: Set<string>

/**
* Node ids that should render with the highlight ring (the same animated
* ring built-in search matches get), driven by the host application's own
* state.
*
* **Union-merge semantics:** these ids merge (union) with the highlight
* set the library derives from its internal Cmd+F search.
*
* **Render-only:** highlighting never pans the viewport, alters match
* counts, or changes selection behaviour.
*
* A node present in BOTH sets renders both visuals — dim opacity with the
* highlight ring nested inside the dimmed group, so the ring inherits the
* dim opacity (identical to what built-in search renders today for a
* query match on a hidden-category node). Omitting the prop or passing an
* empty set is a no-op. Prefer a stable `Set` identity across renders as
* a perf courtesy — unstable Sets merely recompute the merge memo each
* render (tolerable, not required).
*/
highlightedNodeIds?: Set<string>

// --- Styling ---
className?: string
style?: React.CSSProperties
Expand Down Expand Up @@ -573,6 +638,8 @@ export const SystemCanvas = forwardRef<SystemCanvasHandle, SystemCanvasProps>(
onRedo,
collaborators = [],
showMinimap = false,
dimmedNodeIds,
highlightedNodeIds,
className,
style,
},
Expand Down Expand Up @@ -1090,6 +1157,21 @@ export const SystemCanvas = forwardRef<SystemCanvasHandle, SystemCanvasProps>(
[nodes, searchQuery, searchOpen, hiddenCategories]
)

// External emphasis (render-only). Union-merge the host-provided dim/highlight
// sets with the internal search-derived sets. Deliberately NOT folded into
// `computeNodeFilter` above: `matchingIds` feeds the auto-pan-to-single-match
// effect, `matchCount`, and `activeMatchId` — all of which must stay strictly
// search-owned so external emphasis can never pan the viewport or alter match
// counts.
const mergedDimmedIds = useMemo(
() => unionIdSets(dimmedIds, dimmedNodeIds),
[dimmedIds, dimmedNodeIds]
)
const mergedHighlightedIds = useMemo(
() => unionIdSets(matchingIds, highlightedNodeIds),
[matchingIds, highlightedNodeIds]
)

const matchingIdsArray = useMemo(() => Array.from(matchingIds), [matchingIds])
const activeMatchId = matchingIdsArray[searchIndex] as string | undefined

Expand Down Expand Up @@ -2042,8 +2124,8 @@ export const SystemCanvas = forwardRef<SystemCanvasHandle, SystemCanvasProps>(
}
edgeCreateEnabled={editable}
alignmentGuides={editable ? alignmentGuides : undefined}
dimmedNodeIds={dimmedIds}
highlightedNodeIds={matchingIds}
dimmedNodeIds={mergedDimmedIds}
highlightedNodeIds={mergedHighlightedIds}
activeMatchNodeId={activeMatchId}
viewportState={collaboratorViewport}
/>
Expand Down
Loading
Loading