this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: redesign immersive graph builder

+1324 -311
+48
app/(creator)/forms/[id]/graph/page.tsx
··· 1 + import type { Metadata } from "next"; 2 + import { notFound } from "next/navigation"; 3 + 4 + import { FormBuilder } from "@/components/form-builder"; 5 + import { requireServerSessionUser } from "@/lib/auth"; 6 + import { AppError } from "@/lib/errors"; 7 + import { getOwnedFormForBuilder } from "@/lib/forms"; 8 + import { getRequestI18n } from "@/lib/i18n-server"; 9 + import { resolveTitle, withTitle } from "@/lib/metadata"; 10 + 11 + async function getEditableForm(id: string) { 12 + const user = await requireServerSessionUser(); 13 + 14 + try { 15 + return await getOwnedFormForBuilder(user.id, id); 16 + } catch (error) { 17 + if (error instanceof AppError && error.status === 404) { 18 + notFound(); 19 + } 20 + 21 + throw error; 22 + } 23 + } 24 + 25 + export async function generateMetadata({ 26 + params, 27 + }: { 28 + params: Promise<{ id: string }>; 29 + }): Promise<Metadata> { 30 + const { t } = await getRequestI18n(); 31 + const { id } = await params; 32 + const form = await getEditableForm(id); 33 + 34 + return withTitle(resolveTitle(form.title, t("meta.untitledForm"))); 35 + } 36 + 37 + export default async function GraphFormPage({ 38 + params, 39 + }: { 40 + params: Promise<{ id: string }>; 41 + }) { 42 + const { id } = await params; 43 + const form = await getEditableForm(id); 44 + 45 + return ( 46 + <FormBuilder initialForm={form} initialMode="graph" immersiveGraphPage /> 47 + ); 48 + }
+1 -1
app/(creator)/layout.tsx
··· 22 22 const workspaceState = await getActiveWorkspaceForUser(session.user.id); 23 23 24 24 return ( 25 - <main className="mx-auto flex w-full max-w-7xl flex-1 flex-col px-6 py-8 lg:px-10"> 25 + <main className="mx-auto flex w-full max-w-7xl flex-1 flex-col px-6 py-4 lg:px-10"> 26 26 <header className="mb-8 flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-center lg:justify-between"> 27 27 <div className="flex items-center gap-4"> 28 28 <Link
+6 -11
components/builder/branch-rules-editor.tsx
··· 106 106 }} 107 107 className="grid gap-3 rounded-[18px] border border-[color:var(--line)] bg-[var(--surface)] p-3" 108 108 > 109 - <div className="flex items-center gap-2"> 109 + <div className="flex items-start gap-2"> 110 110 <button 111 111 type="button" 112 112 className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl border border-[color:var(--line)] text-[var(--muted)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]" ··· 116 116 > 117 117 <GripVertical className="size-4" /> 118 118 </button> 119 - <div className="grid min-w-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)_minmax(220px,0.9fr)]"> 119 + <div className="grid min-w-0 flex-1 gap-3"> 120 120 <label className="grid gap-2 text-sm text-[var(--muted)]"> 121 121 <span className="font-medium text-[var(--ink)]"> 122 122 {t("builder.branchOperator")} ··· 364 364 } 365 365 366 366 return ( 367 - <div className="grid gap-4 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-4 text-sm text-[var(--muted)]"> 367 + <div className="grid gap-4 text-sm text-[var(--muted)]"> 368 368 <div className="flex items-center justify-between gap-3"> 369 - <div> 370 - <p className="font-medium text-[var(--ink)]"> 371 - {t("builder.branchingTitle")} 372 - </p> 373 - <p className="mt-1 text-xs text-[var(--muted)]"> 374 - {t("builder.branchingDescription")} 375 - </p> 376 - </div> 369 + <span className="font-medium text-[var(--ink)]"> 370 + {t("builder.branchingTitle")} 371 + </span> 377 372 <Button 378 373 variant="secondary" 379 374 size="sm"
+1023 -253
components/builder/branching-graph-workspace.tsx
··· 1 1 "use client"; 2 2 3 - import { useEffect, useMemo, useRef, useState } from "react"; 4 - import { LoaderCircle } from "lucide-react"; 3 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 4 + import { PanelLeftClose } from "lucide-react"; 5 5 import type { 6 6 Dispatch, 7 7 PointerEvent as ReactPointerEvent, 8 + ReactNode, 8 9 SetStateAction, 9 - WheelEvent as ReactWheelEvent, 10 10 } from "react"; 11 11 12 - import { BranchRulesEditor } from "@/components/builder/branch-rules-editor"; 12 + import { BlockEditorPanel } from "@/components/form-builder-panels"; 13 13 import { useI18n } from "@/components/i18n-provider"; 14 - import { AuthoredMarkdown } from "@/components/ui/authored-markdown"; 15 14 import { Badge } from "@/components/ui/badge"; 16 15 import { Button } from "@/components/ui/button"; 17 16 import { Card } from "@/components/ui/card"; 18 - import { 19 - blockTypeTranslationKeys, 20 - isQuestionBlock, 21 - type BranchOperator, 22 - } from "@/lib/blocks"; 23 - import { 24 - getBlockDisplayLabel, 25 - type BranchValidationIssue, 26 - } from "@/lib/branching"; 17 + import { blockTypeTranslationKeys, type BranchOperator } from "@/lib/blocks"; 18 + import { type BranchValidationIssue } from "@/lib/branching"; 27 19 import { 28 20 buildBranchingGraph, 29 21 type BranchingGraphEdge, 30 22 } from "@/lib/branching-graph"; 31 - import type { BranchRuleDraft } from "@/lib/form-builder-drafts"; 23 + import type { 24 + BranchRuleDraft, 25 + ChoiceOptionDraft, 26 + } from "@/lib/form-builder-drafts"; 32 27 import type { BuilderBlock } from "@/lib/form-types"; 33 28 import { cn } from "@/lib/utils"; 29 + import type { LucideIcon } from "lucide-react"; 34 30 35 31 function getEdgePath( 36 32 source: { x: number; y: number; width: number; height: number }, ··· 137 133 pointerId: number; 138 134 startX: number; 139 135 startY: number; 140 - scrollLeft: number; 141 - scrollTop: number; 136 + startCameraX: number; 137 + startCameraY: number; 138 + }; 139 + 140 + type GraphCamera = { 141 + x: number; 142 + y: number; 143 + zoom: number; 142 144 }; 143 145 144 146 type GraphViewport = { ··· 147 149 width: number; 148 150 height: number; 149 151 }; 152 + 153 + const MIN_GRAPH_ZOOM = 0.5; 154 + const MAX_GRAPH_ZOOM = 2; 155 + const DEFAULT_GRAPH_ZOOM = 1; 150 156 151 157 type GraphNodePositionOverride = { 152 158 x: number; 153 159 y: number; 154 160 }; 155 161 162 + const GRAPH_LAYOUT_STORAGE_KEY_PREFIX = "lively-forms:branching-graph-layout:"; 163 + const GRAPH_INSPECTOR_WIDTH_STORAGE_KEY_PREFIX = 164 + "lively-forms:branching-graph-inspector-width:"; 165 + const GRAPH_CANVAS_PADDING = 720; 166 + const DEFAULT_IMMERSIVE_INSPECTOR_WIDTH = 560; 167 + const MIN_IMMERSIVE_INSPECTOR_WIDTH = 420; 168 + const MAX_IMMERSIVE_INSPECTOR_WIDTH = 960; 169 + 170 + type ImmersiveInspectorWidthBounds = { 171 + min: number; 172 + max: number; 173 + }; 174 + 175 + function getGraphLayoutStorageKey(formId: string) { 176 + return `${GRAPH_LAYOUT_STORAGE_KEY_PREFIX}${formId}`; 177 + } 178 + 179 + function getGraphInspectorWidthStorageKey(formId: string) { 180 + return `${GRAPH_INSPECTOR_WIDTH_STORAGE_KEY_PREFIX}${formId}`; 181 + } 182 + 183 + function getImmersiveInspectorWidthBounds(viewportWidth: number) { 184 + const availableWidth = Math.max(280, viewportWidth - 32); 185 + const preferredMaxWidth = Math.min( 186 + MAX_IMMERSIVE_INSPECTOR_WIDTH, 187 + Math.round(viewportWidth * 0.5), 188 + ); 189 + const min = Math.min(MIN_IMMERSIVE_INSPECTOR_WIDTH, availableWidth); 190 + const max = Math.max(min, Math.min(availableWidth, preferredMaxWidth)); 191 + 192 + return { min, max } satisfies ImmersiveInspectorWidthBounds; 193 + } 194 + 195 + function clampImmersiveInspectorWidth(width: number, viewportWidth: number) { 196 + const bounds = getImmersiveInspectorWidthBounds(viewportWidth); 197 + return Math.min(bounds.max, Math.max(bounds.min, width)); 198 + } 199 + 200 + function readPersistedInspectorWidth(formId: string) { 201 + if (typeof window === "undefined") { 202 + return null; 203 + } 204 + 205 + try { 206 + const rawValue = window.localStorage.getItem( 207 + getGraphInspectorWidthStorageKey(formId), 208 + ); 209 + 210 + if (!rawValue) { 211 + return null; 212 + } 213 + 214 + const parsedValue = Number(rawValue); 215 + return Number.isFinite(parsedValue) ? parsedValue : null; 216 + } catch { 217 + return null; 218 + } 219 + } 220 + 221 + function persistInspectorWidth(formId: string, width: number) { 222 + if (typeof window === "undefined") { 223 + return; 224 + } 225 + 226 + try { 227 + window.localStorage.setItem( 228 + getGraphInspectorWidthStorageKey(formId), 229 + String(width), 230 + ); 231 + } catch { 232 + // Ignore storage failures so graph mode remains usable. 233 + } 234 + } 235 + 236 + function readPersistedGraphLayout(formId: string) { 237 + if (typeof window === "undefined") { 238 + return {} as Record<string, GraphNodePositionOverride>; 239 + } 240 + 241 + try { 242 + const rawValue = window.localStorage.getItem( 243 + getGraphLayoutStorageKey(formId), 244 + ); 245 + 246 + if (!rawValue) { 247 + return {} as Record<string, GraphNodePositionOverride>; 248 + } 249 + 250 + const parsedValue = JSON.parse(rawValue) as Record< 251 + string, 252 + Partial<GraphNodePositionOverride> 253 + >; 254 + 255 + return Object.fromEntries( 256 + Object.entries(parsedValue).flatMap(([blockId, position]) => 257 + typeof position?.x === "number" && typeof position?.y === "number" 258 + ? [[blockId, { x: position.x, y: position.y }]] 259 + : [], 260 + ), 261 + ); 262 + } catch { 263 + return {} as Record<string, GraphNodePositionOverride>; 264 + } 265 + } 266 + 267 + function persistGraphLayout( 268 + formId: string, 269 + overrides: Record<string, GraphNodePositionOverride>, 270 + ) { 271 + if (typeof window === "undefined") { 272 + return; 273 + } 274 + 275 + try { 276 + if (!Object.keys(overrides).length) { 277 + window.localStorage.removeItem(getGraphLayoutStorageKey(formId)); 278 + return; 279 + } 280 + 281 + window.localStorage.setItem( 282 + getGraphLayoutStorageKey(formId), 283 + JSON.stringify(overrides), 284 + ); 285 + } catch { 286 + // Ignore storage failures so graph mode remains usable. 287 + } 288 + } 289 + 156 290 type GraphNodeDragState = { 157 291 pointerId: number; 158 - blockId: string; 292 + blockIds: string[]; 159 293 startClientX: number; 160 294 startClientY: number; 161 - startNodeX: number; 162 - startNodeY: number; 295 + startPositions: Record<string, GraphNodePositionOverride>; 163 296 moved: boolean; 164 297 }; 165 298 299 + type GraphSelectionRect = { 300 + pointerId: number; 301 + startX: number; 302 + startY: number; 303 + currentX: number; 304 + currentY: number; 305 + additive: boolean; 306 + }; 307 + 308 + type ImmersiveInspectorResizeState = { 309 + startClientX: number; 310 + startWidth: number; 311 + }; 312 + 313 + type SafariGestureEvent = Event & { 314 + clientX: number; 315 + clientY: number; 316 + scale: number; 317 + }; 318 + 166 319 export function BranchingGraphWorkspace({ 320 + formId, 167 321 allBlocks, 168 322 selectedBlockId, 169 - selectedGraphEdgeId, 170 323 blockDraft, 171 324 branchRulesDraft, 172 325 branchValidationIssues, 326 + selectedBlockIcon, 327 + choiceOptionsDraft, 328 + setChoiceOptionsDraft, 173 329 setBlockDraft, 174 330 setBranchRulesDraft, 331 + deleteBlock, 175 332 saveBlock, 176 333 busy, 177 334 onSelectBlock, 178 - onSelectEdge, 335 + immersive = false, 336 + hideInspector = false, 337 + immersiveInspectorOpen = false, 338 + immersiveFormTitle, 339 + onCloseImmersiveInspector, 340 + overlay, 179 341 }: { 342 + formId: string; 180 343 allBlocks: BuilderBlock[]; 181 344 selectedBlockId: string | null; 182 - selectedGraphEdgeId: string | null; 345 + selectedGraphEdgeId?: string | null; 183 346 blockDraft: BuilderBlock | null; 184 347 branchRulesDraft: BranchRuleDraft[]; 185 348 branchValidationIssues: BranchValidationIssue[]; 349 + selectedBlockIcon: LucideIcon | null; 350 + choiceOptionsDraft: ChoiceOptionDraft[]; 351 + setChoiceOptionsDraft: Dispatch<SetStateAction<ChoiceOptionDraft[]>>; 186 352 setBlockDraft: Dispatch<SetStateAction<BuilderBlock | null>>; 187 353 setBranchRulesDraft: Dispatch<SetStateAction<BranchRuleDraft[]>>; 354 + deleteBlock: (blockId: string) => void; 188 355 saveBlock: () => Promise<boolean>; 189 356 busy: string | null; 190 357 onSelectBlock: (blockId: string) => void; 191 - onSelectEdge: (edgeId: string, sourceBlockId: string) => void; 358 + immersive?: boolean; 359 + hideInspector?: boolean; 360 + immersiveInspectorOpen?: boolean; 361 + immersiveFormTitle?: string; 362 + onCloseImmersiveInspector?: () => void; 363 + overlay?: ReactNode; 192 364 }) { 193 365 const { t } = useI18n(); 194 366 const graphScrollRef = useRef<HTMLDivElement | null>(null); 367 + const graphSceneRef = useRef<HTMLDivElement | null>(null); 195 368 const graphPanStateRef = useRef<GraphPanState | null>(null); 196 369 const graphNodeDragStateRef = useRef<GraphNodeDragState | null>(null); 370 + const immersiveInspectorResizeStateRef = 371 + useRef<ImmersiveInspectorResizeState | null>(null); 197 372 const suppressNodeClickRef = useRef<string | null>(null); 198 373 const hasInitializedGraphCameraRef = useRef(false); 374 + const hasLoadedPersistedGraphLayoutRef = useRef(false); 375 + const graphCameraRef = useRef<GraphCamera>({ 376 + x: 0, 377 + y: 0, 378 + zoom: DEFAULT_GRAPH_ZOOM, 379 + }); 380 + const gestureStartZoomRef = useRef(DEFAULT_GRAPH_ZOOM); 381 + const isNativeGestureZoomingRef = useRef(false); 199 382 const [isPanningGraph, setIsPanningGraph] = useState(false); 383 + const [isSpacePressed, setIsSpacePressed] = useState(false); 200 384 const [graphNodePositionOverrides, setGraphNodePositionOverrides] = useState< 201 385 Record<string, GraphNodePositionOverride> 202 386 >({}); 387 + const [immersiveInspectorWidth, setImmersiveInspectorWidth] = useState( 388 + DEFAULT_IMMERSIVE_INSPECTOR_WIDTH, 389 + ); 390 + const [isResizingImmersiveInspector, setIsResizingImmersiveInspector] = 391 + useState(false); 392 + const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>(() => 393 + selectedBlockId ? [selectedBlockId] : [], 394 + ); 395 + const [selectionRect, setSelectionRect] = useState<GraphSelectionRect | null>( 396 + null, 397 + ); 203 398 const [graphViewport, setGraphViewport] = useState<GraphViewport>({ 204 399 left: 0, 205 400 top: 0, ··· 207 402 height: 0, 208 403 }); 209 404 const graph = useMemo(() => buildBranchingGraph(allBlocks), [allBlocks]); 405 + const viewportPadding = immersive ? GRAPH_CANVAS_PADDING : 0; 406 + 407 + useEffect(() => { 408 + const persistedOverrides = readPersistedGraphLayout(formId); 409 + const validBlockIds = new Set(allBlocks.map((block) => block.id)); 410 + const nextOverrides = Object.fromEntries( 411 + Object.entries(persistedOverrides).filter(([blockId]) => 412 + validBlockIds.has(blockId), 413 + ), 414 + ); 415 + 416 + hasLoadedPersistedGraphLayoutRef.current = false; 417 + setGraphNodePositionOverrides(nextOverrides); 418 + persistGraphLayout(formId, nextOverrides); 419 + hasLoadedPersistedGraphLayoutRef.current = true; 420 + }, [formId, allBlocks]); 421 + 422 + useEffect(() => { 423 + if (typeof window === "undefined") { 424 + return; 425 + } 426 + 427 + const persistedWidth = readPersistedInspectorWidth(formId); 428 + const nextWidth = clampImmersiveInspectorWidth( 429 + persistedWidth ?? DEFAULT_IMMERSIVE_INSPECTOR_WIDTH, 430 + window.innerWidth, 431 + ); 432 + 433 + setImmersiveInspectorWidth(nextWidth); 434 + persistInspectorWidth(formId, nextWidth); 435 + }, [formId]); 436 + 437 + useEffect(() => { 438 + if (typeof window === "undefined") { 439 + return; 440 + } 441 + 442 + function handleResize() { 443 + setImmersiveInspectorWidth((current) => { 444 + const nextWidth = clampImmersiveInspectorWidth( 445 + current, 446 + window.innerWidth, 447 + ); 448 + persistInspectorWidth(formId, nextWidth); 449 + return nextWidth; 450 + }); 451 + } 452 + 453 + window.addEventListener("resize", handleResize); 454 + return () => window.removeEventListener("resize", handleResize); 455 + }, [formId]); 456 + 457 + useEffect(() => { 458 + if (selectedBlockId) { 459 + setSelectedNodeIds((current) => 460 + current.length > 1 && current.includes(selectedBlockId) 461 + ? current 462 + : [selectedBlockId], 463 + ); 464 + return; 465 + } 466 + 467 + setSelectedNodeIds((current) => (current.length > 1 ? current : [])); 468 + }, [selectedBlockId]); 469 + 470 + useEffect(() => { 471 + if (typeof window === "undefined") { 472 + return; 473 + } 474 + 475 + function handleKeyDown(event: KeyboardEvent) { 476 + if (event.code === "Space") { 477 + setIsSpacePressed(true); 478 + } 479 + } 480 + 481 + function handleKeyUp(event: KeyboardEvent) { 482 + if (event.code === "Space") { 483 + setIsSpacePressed(false); 484 + } 485 + } 486 + 487 + function handleBlur() { 488 + setIsSpacePressed(false); 489 + } 490 + 491 + window.addEventListener("keydown", handleKeyDown); 492 + window.addEventListener("keyup", handleKeyUp); 493 + window.addEventListener("blur", handleBlur); 494 + 495 + return () => { 496 + window.removeEventListener("keydown", handleKeyDown); 497 + window.removeEventListener("keyup", handleKeyUp); 498 + window.removeEventListener("blur", handleBlur); 499 + }; 500 + }, []); 501 + 502 + useEffect(() => { 503 + if (typeof window === "undefined" || !isResizingImmersiveInspector) { 504 + return; 505 + } 506 + 507 + function handlePointerMove(event: PointerEvent) { 508 + const resizeState = immersiveInspectorResizeStateRef.current; 509 + 510 + if (!resizeState) { 511 + return; 512 + } 513 + 514 + const deltaX = event.clientX - resizeState.startClientX; 515 + const nextWidth = clampImmersiveInspectorWidth( 516 + resizeState.startWidth + deltaX, 517 + window.innerWidth, 518 + ); 519 + 520 + setImmersiveInspectorWidth(nextWidth); 521 + } 522 + 523 + function stopResizing() { 524 + immersiveInspectorResizeStateRef.current = null; 525 + setIsResizingImmersiveInspector(false); 526 + } 527 + 528 + function handlePointerUp() { 529 + persistInspectorWidth(formId, immersiveInspectorWidth); 530 + stopResizing(); 531 + } 532 + 533 + function handleBlur() { 534 + persistInspectorWidth(formId, immersiveInspectorWidth); 535 + stopResizing(); 536 + } 537 + 538 + const previousBodyCursor = document.body.style.cursor; 539 + const previousBodyUserSelect = document.body.style.userSelect; 540 + 541 + document.body.style.cursor = "ew-resize"; 542 + document.body.style.userSelect = "none"; 543 + window.addEventListener("pointermove", handlePointerMove); 544 + window.addEventListener("pointerup", handlePointerUp); 545 + window.addEventListener("pointercancel", handlePointerUp); 546 + window.addEventListener("blur", handleBlur); 547 + 548 + return () => { 549 + document.body.style.cursor = previousBodyCursor; 550 + document.body.style.userSelect = previousBodyUserSelect; 551 + window.removeEventListener("pointermove", handlePointerMove); 552 + window.removeEventListener("pointerup", handlePointerUp); 553 + window.removeEventListener("pointercancel", handlePointerUp); 554 + window.removeEventListener("blur", handleBlur); 555 + }; 556 + }, [formId, immersiveInspectorWidth, isResizingImmersiveInspector]); 557 + 210 558 const positionedNodes = useMemo( 211 559 () => 212 560 graph.nodes.map((node) => { ··· 219 567 () => new Map(positionedNodes.map((node) => [node.blockId, node])), 220 568 [positionedNodes], 221 569 ); 222 - const selectedEdge = 223 - graph.edges.find((edge) => edge.id === selectedGraphEdgeId) ?? null; 224 - const selectedEdgeTarget = selectedEdge 225 - ? (allBlocks.find((block) => block.id === selectedEdge.targetBlockId) ?? 226 - null) 227 - : null; 228 - const isSavingSelectedBlock = Boolean( 229 - blockDraft && busy === `block-${blockDraft.id}`, 570 + const renderedGraphWidth = positionedNodes.length 571 + ? Math.max(...positionedNodes.map((node) => node.x + node.width)) 572 + : graph.width; 573 + const renderedGraphHeight = positionedNodes.length 574 + ? Math.max(...positionedNodes.map((node) => node.y + node.height)) 575 + : graph.height; 576 + const selectedNodeIdSet = useMemo( 577 + () => new Set(selectedNodeIds), 578 + [selectedNodeIds], 230 579 ); 231 580 232 - function updateConfig(patch: Record<string, unknown>) { 233 - setBlockDraft((current) => 234 - current 235 - ? { 236 - ...current, 237 - config: { 238 - ...(current.config as Record<string, unknown>), 239 - ...patch, 240 - } as BuilderBlock["config"], 241 - } 242 - : current, 243 - ); 581 + function clampGraphZoom(value: number) { 582 + return Math.min(MAX_GRAPH_ZOOM, Math.max(MIN_GRAPH_ZOOM, value)); 244 583 } 245 584 246 - function syncGraphViewport() { 585 + function getGraphCoordinates(clientX: number, clientY: number) { 247 586 const container = graphScrollRef.current; 248 587 249 588 if (!container) { 250 - return; 589 + return null; 251 590 } 252 591 253 - setGraphViewport({ 254 - left: container.scrollLeft, 255 - top: container.scrollTop, 256 - width: container.clientWidth, 257 - height: container.clientHeight, 258 - }); 592 + const rect = container.getBoundingClientRect(); 593 + return { 594 + x: 595 + (clientX - rect.left - graphCameraRef.current.x) / 596 + graphCameraRef.current.zoom, 597 + y: 598 + (clientY - rect.top - graphCameraRef.current.y) / 599 + graphCameraRef.current.zoom, 600 + }; 259 601 } 260 602 603 + function getSelectionBounds(rect: GraphSelectionRect) { 604 + return { 605 + left: Math.min(rect.startX, rect.currentX), 606 + right: Math.max(rect.startX, rect.currentX), 607 + top: Math.min(rect.startY, rect.currentY), 608 + bottom: Math.max(rect.startY, rect.currentY), 609 + }; 610 + } 611 + 612 + function getNodesWithinSelection(rect: GraphSelectionRect) { 613 + const bounds = getSelectionBounds(rect); 614 + 615 + return positionedNodes 616 + .filter((node) => { 617 + const left = node.x; 618 + const top = node.y; 619 + const right = left + node.width; 620 + const bottom = top + node.height; 621 + 622 + return ( 623 + right >= bounds.left && 624 + left <= bounds.right && 625 + bottom >= bounds.top && 626 + top <= bounds.bottom 627 + ); 628 + }) 629 + .map((node) => node.blockId); 630 + } 631 + 632 + const syncGraphViewport = useCallback( 633 + (camera: GraphCamera = graphCameraRef.current) => { 634 + const container = graphScrollRef.current; 635 + 636 + if (!container) { 637 + return; 638 + } 639 + 640 + setGraphViewport({ 641 + left: -camera.x / camera.zoom, 642 + top: -camera.y / camera.zoom, 643 + width: container.clientWidth / camera.zoom, 644 + height: container.clientHeight / camera.zoom, 645 + }); 646 + }, 647 + [], 648 + ); 649 + 650 + const applyGraphCamera = useCallback( 651 + (nextCamera: GraphCamera) => { 652 + const container = graphScrollRef.current; 653 + const zoom = clampGraphZoom(nextCamera.zoom); 654 + const scaledWidth = renderedGraphWidth * zoom; 655 + const scaledHeight = renderedGraphHeight * zoom; 656 + 657 + let x = nextCamera.x; 658 + let y = nextCamera.y; 659 + 660 + if (container) { 661 + const extraPanX = viewportPadding; 662 + const extraPanY = viewportPadding; 663 + const minX = Math.min( 664 + 0, 665 + container.clientWidth - scaledWidth - extraPanX, 666 + ); 667 + const minY = Math.min( 668 + 0, 669 + container.clientHeight - scaledHeight - extraPanY, 670 + ); 671 + const maxX = 672 + scaledWidth <= container.clientWidth 673 + ? (container.clientWidth - scaledWidth) / 2 + extraPanX 674 + : extraPanX; 675 + const maxY = 676 + scaledHeight <= container.clientHeight 677 + ? (container.clientHeight - scaledHeight) / 2 + extraPanY 678 + : extraPanY; 679 + 680 + x = Math.min(maxX, Math.max(minX, x)); 681 + y = Math.min(maxY, Math.max(minY, y)); 682 + } 683 + 684 + const camera = { 685 + x, 686 + y, 687 + zoom, 688 + } satisfies GraphCamera; 689 + 690 + graphCameraRef.current = camera; 691 + 692 + if (graphSceneRef.current) { 693 + graphSceneRef.current.style.transform = `translate(${camera.x}px, ${camera.y}px) scale(${camera.zoom})`; 694 + } 695 + 696 + syncGraphViewport(camera); 697 + }, 698 + [ 699 + renderedGraphHeight, 700 + renderedGraphWidth, 701 + syncGraphViewport, 702 + viewportPadding, 703 + ], 704 + ); 705 + 706 + const updateGraphZoomAtPoint = useCallback( 707 + (nextZoom: number, clientX: number, clientY: number) => { 708 + const container = graphScrollRef.current; 709 + const previousCamera = graphCameraRef.current; 710 + const clampedZoom = clampGraphZoom(nextZoom); 711 + 712 + if (!container || clampedZoom === previousCamera.zoom) { 713 + return; 714 + } 715 + 716 + const rect = container.getBoundingClientRect(); 717 + const localX = clientX - rect.left; 718 + const localY = clientY - rect.top; 719 + const graphX = (localX - previousCamera.x) / previousCamera.zoom; 720 + const graphY = (localY - previousCamera.y) / previousCamera.zoom; 721 + 722 + applyGraphCamera({ 723 + x: localX - graphX * clampedZoom, 724 + y: localY - graphY * clampedZoom, 725 + zoom: clampedZoom, 726 + }); 727 + }, 728 + [applyGraphCamera], 729 + ); 730 + 261 731 useEffect(() => { 262 732 const container = graphScrollRef.current; 263 733 const firstNode = positionedNodes[0]; ··· 266 736 return; 267 737 } 268 738 739 + const containerWidth = container.clientWidth; 740 + const containerHeight = container.clientHeight; 741 + 269 742 if (!hasInitializedGraphCameraRef.current && firstNode) { 270 - const nextScrollTop = Math.max( 271 - 0, 272 - firstNode.y + firstNode.height / 2 - container.clientHeight / 2, 273 - ); 274 - container.scrollTop = nextScrollTop; 743 + if (containerWidth <= 0 || containerHeight <= 0) { 744 + return; 745 + } 746 + 747 + applyGraphCamera({ 748 + x: 0, 749 + y: 750 + containerHeight / 2 - 751 + (firstNode.y + firstNode.height / 2) * DEFAULT_GRAPH_ZOOM, 752 + zoom: DEFAULT_GRAPH_ZOOM, 753 + }); 275 754 hasInitializedGraphCameraRef.current = true; 755 + return; 276 756 } 277 757 278 - syncGraphViewport(); 279 - }, [graph.width, graph.height, positionedNodes]); 758 + applyGraphCamera(graphCameraRef.current); 759 + }, [applyGraphCamera, positionedNodes]); 760 + 761 + useEffect(() => { 762 + const container = graphScrollRef.current; 763 + 764 + if (!container || typeof ResizeObserver === "undefined") { 765 + return; 766 + } 767 + 768 + const observer = new ResizeObserver(() => { 769 + syncGraphViewport(); 770 + }); 771 + 772 + observer.observe(container); 773 + return () => observer.disconnect(); 774 + }, [syncGraphViewport]); 775 + 776 + useEffect(() => { 777 + const container = graphScrollRef.current; 778 + 779 + if (!container || typeof window === "undefined") { 780 + return; 781 + } 782 + 783 + function handleNativeWheel(event: WheelEvent) { 784 + event.preventDefault(); 785 + event.stopPropagation(); 786 + 787 + if (event.ctrlKey) { 788 + if (isNativeGestureZoomingRef.current) { 789 + return; 790 + } 791 + 792 + updateGraphZoomAtPoint( 793 + graphCameraRef.current.zoom * Math.exp(-event.deltaY * 0.004), 794 + event.clientX, 795 + event.clientY, 796 + ); 797 + return; 798 + } 799 + 800 + applyGraphCamera({ 801 + x: graphCameraRef.current.x - event.deltaX, 802 + y: graphCameraRef.current.y - event.deltaY, 803 + zoom: graphCameraRef.current.zoom, 804 + }); 805 + } 806 + 807 + function handleGestureStart(event: Event) { 808 + event.preventDefault(); 809 + event.stopPropagation(); 810 + isNativeGestureZoomingRef.current = true; 811 + gestureStartZoomRef.current = graphCameraRef.current.zoom; 812 + 813 + if (typeof document !== "undefined") { 814 + document.body.style.overscrollBehavior = "none"; 815 + } 816 + } 817 + 818 + function handleGestureChange(event: Event) { 819 + const gestureEvent = event as SafariGestureEvent; 820 + event.preventDefault(); 821 + event.stopPropagation(); 822 + isNativeGestureZoomingRef.current = true; 823 + updateGraphZoomAtPoint( 824 + gestureStartZoomRef.current * gestureEvent.scale, 825 + gestureEvent.clientX, 826 + gestureEvent.clientY, 827 + ); 828 + } 829 + 830 + function handleGestureEnd(event: Event) { 831 + event.preventDefault(); 832 + event.stopPropagation(); 833 + isNativeGestureZoomingRef.current = false; 834 + gestureStartZoomRef.current = graphCameraRef.current.zoom; 835 + 836 + if (typeof document !== "undefined") { 837 + document.body.style.overscrollBehavior = ""; 838 + } 839 + } 840 + 841 + container.addEventListener("wheel", handleNativeWheel, { 842 + passive: false, 843 + }); 844 + container.addEventListener("gesturestart", handleGestureStart, { 845 + passive: false, 846 + } as AddEventListenerOptions); 847 + container.addEventListener("gesturechange", handleGestureChange, { 848 + passive: false, 849 + } as AddEventListenerOptions); 850 + container.addEventListener("gestureend", handleGestureEnd, { 851 + passive: false, 852 + } as AddEventListenerOptions); 853 + 854 + return () => { 855 + container.removeEventListener("wheel", handleNativeWheel); 856 + container.removeEventListener("gesturestart", handleGestureStart); 857 + container.removeEventListener("gesturechange", handleGestureChange); 858 + container.removeEventListener("gestureend", handleGestureEnd); 859 + 860 + if (typeof document !== "undefined") { 861 + document.body.style.overscrollBehavior = ""; 862 + } 863 + }; 864 + }, [applyGraphCamera, updateGraphZoomAtPoint]); 280 865 281 866 function handleGraphPointerDown(event: ReactPointerEvent<HTMLDivElement>) { 282 867 if (event.button !== 0) { 283 868 return; 284 869 } 285 870 871 + const container = graphScrollRef.current; 872 + 873 + if (!container) { 874 + return; 875 + } 876 + 877 + if (isSpacePressed) { 878 + graphPanStateRef.current = { 879 + pointerId: event.pointerId, 880 + startX: event.clientX, 881 + startY: event.clientY, 882 + startCameraX: graphCameraRef.current.x, 883 + startCameraY: graphCameraRef.current.y, 884 + }; 885 + 886 + container.setPointerCapture(event.pointerId); 887 + setIsPanningGraph(true); 888 + return; 889 + } 890 + 286 891 if ((event.target as HTMLElement).closest("button")) { 287 892 return; 288 893 } 289 894 290 - const container = graphScrollRef.current; 895 + const graphPoint = getGraphCoordinates(event.clientX, event.clientY); 291 896 292 - if (!container) { 897 + if (!graphPoint) { 293 898 return; 294 899 } 295 900 296 - graphPanStateRef.current = { 901 + const nextSelection = { 297 902 pointerId: event.pointerId, 298 - startX: event.clientX, 299 - startY: event.clientY, 300 - scrollLeft: container.scrollLeft, 301 - scrollTop: container.scrollTop, 302 - }; 903 + startX: graphPoint.x, 904 + startY: graphPoint.y, 905 + currentX: graphPoint.x, 906 + currentY: graphPoint.y, 907 + additive: event.metaKey || event.ctrlKey || event.shiftKey, 908 + } satisfies GraphSelectionRect; 303 909 910 + setSelectionRect(nextSelection); 304 911 container.setPointerCapture(event.pointerId); 305 - setIsPanningGraph(true); 306 912 } 307 913 308 914 function handleGraphPointerMove(event: ReactPointerEvent<HTMLDivElement>) { 309 915 const container = graphScrollRef.current; 310 916 const panState = graphPanStateRef.current; 917 + const activeSelectionRect = selectionRect; 311 918 312 - if (!container || !panState || panState.pointerId !== event.pointerId) { 919 + if (container && panState && panState.pointerId === event.pointerId) { 920 + applyGraphCamera({ 921 + x: panState.startCameraX + (event.clientX - panState.startX), 922 + y: panState.startCameraY + (event.clientY - panState.startY), 923 + zoom: graphCameraRef.current.zoom, 924 + }); 313 925 return; 314 926 } 315 927 316 - container.scrollLeft = 317 - panState.scrollLeft - (event.clientX - panState.startX); 318 - container.scrollTop = 319 - panState.scrollTop - (event.clientY - panState.startY); 320 - syncGraphViewport(); 928 + if ( 929 + !container || 930 + !activeSelectionRect || 931 + activeSelectionRect.pointerId !== event.pointerId 932 + ) { 933 + return; 934 + } 935 + 936 + const graphPoint = getGraphCoordinates(event.clientX, event.clientY); 937 + 938 + if (!graphPoint) { 939 + return; 940 + } 941 + 942 + const nextSelection = { 943 + ...activeSelectionRect, 944 + currentX: graphPoint.x, 945 + currentY: graphPoint.y, 946 + } satisfies GraphSelectionRect; 947 + 948 + setSelectionRect(nextSelection); 949 + 950 + const selectedIds = getNodesWithinSelection(nextSelection); 951 + setSelectedNodeIds((current) => 952 + nextSelection.additive 953 + ? Array.from(new Set([...current, ...selectedIds])) 954 + : selectedIds, 955 + ); 321 956 } 322 957 323 958 function handleGraphPointerEnd(event: ReactPointerEvent<HTMLDivElement>) { 324 959 const container = graphScrollRef.current; 325 960 const panState = graphPanStateRef.current; 961 + const activeSelectionRect = selectionRect; 962 + 963 + if (!container) { 964 + return; 965 + } 966 + 967 + if (panState && panState.pointerId === event.pointerId) { 968 + if (container.hasPointerCapture(event.pointerId)) { 969 + container.releasePointerCapture(event.pointerId); 970 + } 971 + 972 + graphPanStateRef.current = null; 973 + setIsPanningGraph(false); 974 + return; 975 + } 326 976 327 - if (!container || !panState || panState.pointerId !== event.pointerId) { 977 + if ( 978 + !activeSelectionRect || 979 + activeSelectionRect.pointerId !== event.pointerId 980 + ) { 328 981 return; 329 982 } 330 983 ··· 332 985 container.releasePointerCapture(event.pointerId); 333 986 } 334 987 335 - graphPanStateRef.current = null; 336 - setIsPanningGraph(false); 988 + const selectedIds = getNodesWithinSelection(activeSelectionRect); 989 + setSelectedNodeIds((current) => 990 + activeSelectionRect.additive 991 + ? Array.from(new Set([...current, ...selectedIds])) 992 + : selectedIds, 993 + ); 994 + setSelectionRect(null); 337 995 } 338 996 339 997 function handleGraphNodePointerDown( 340 998 event: ReactPointerEvent<HTMLButtonElement>, 341 999 node: (typeof positionedNodes)[number], 342 1000 ) { 343 - if (event.button !== 0) { 1001 + if (event.button !== 0 || isSpacePressed) { 344 1002 return; 345 1003 } 346 1004 347 1005 event.preventDefault(); 348 1006 event.stopPropagation(); 349 1007 1008 + const isAdditiveSelection = 1009 + event.metaKey || event.ctrlKey || event.shiftKey; 1010 + 1011 + if (isAdditiveSelection) { 1012 + return; 1013 + } 1014 + 1015 + const blockIdsToDrag = selectedNodeIdSet.has(node.blockId) 1016 + ? selectedNodeIds 1017 + : [node.blockId]; 1018 + const startPositions = Object.fromEntries( 1019 + blockIdsToDrag.map((blockId) => { 1020 + const dragNode = nodesById.get(blockId); 1021 + return [ 1022 + blockId, 1023 + { 1024 + x: dragNode?.x ?? 24, 1025 + y: dragNode?.y ?? 24, 1026 + }, 1027 + ]; 1028 + }), 1029 + ); 1030 + 1031 + setSelectedNodeIds(blockIdsToDrag); 1032 + 350 1033 graphNodeDragStateRef.current = { 351 1034 pointerId: event.pointerId, 352 - blockId: node.blockId, 1035 + blockIds: blockIdsToDrag, 353 1036 startClientX: event.clientX, 354 1037 startClientY: event.clientY, 355 - startNodeX: node.x, 356 - startNodeY: node.y, 1038 + startPositions, 357 1039 moved: false, 358 1040 }; 359 1041 360 - event.currentTarget.setPointerCapture(event.pointerId); 1042 + if (event.currentTarget.setPointerCapture) { 1043 + event.currentTarget.setPointerCapture(event.pointerId); 1044 + } 361 1045 } 362 1046 363 1047 function handleGraphNodePointerMove( ··· 376 1060 dragState.moved = true; 377 1061 } 378 1062 379 - setGraphNodePositionOverrides((current) => ({ 380 - ...current, 381 - [dragState.blockId]: { 382 - x: Math.max(24, dragState.startNodeX + deltaX), 383 - y: Math.max(24, dragState.startNodeY + deltaY), 384 - }, 385 - })); 1063 + if (!dragState.moved) { 1064 + return; 1065 + } 1066 + 1067 + const nextOverrides = { 1068 + ...graphNodePositionOverrides, 1069 + }; 1070 + 1071 + for (const blockId of dragState.blockIds) { 1072 + const startPosition = dragState.startPositions[blockId]; 1073 + 1074 + if (!startPosition) { 1075 + continue; 1076 + } 1077 + 1078 + nextOverrides[blockId] = { 1079 + x: Math.max(24, startPosition.x + deltaX / graphCameraRef.current.zoom), 1080 + y: Math.max(24, startPosition.y + deltaY / graphCameraRef.current.zoom), 1081 + }; 1082 + } 1083 + 1084 + setGraphNodePositionOverrides(nextOverrides); 1085 + 1086 + if (hasLoadedPersistedGraphLayoutRef.current) { 1087 + persistGraphLayout(formId, nextOverrides); 1088 + } 386 1089 } 387 1090 388 1091 function handleGraphNodePointerUp( 389 1092 event: ReactPointerEvent<HTMLButtonElement>, 1093 + node: (typeof positionedNodes)[number], 390 1094 ) { 391 1095 const dragState = graphNodeDragStateRef.current; 392 - 393 - if (!dragState || dragState.pointerId !== event.pointerId) { 394 - return; 395 - } 1096 + const isAdditiveSelection = 1097 + event.metaKey || event.ctrlKey || event.shiftKey; 396 1098 397 - if (event.currentTarget.hasPointerCapture(event.pointerId)) { 1099 + if ( 1100 + event.currentTarget.hasPointerCapture?.(event.pointerId) && 1101 + event.currentTarget.releasePointerCapture 1102 + ) { 398 1103 event.currentTarget.releasePointerCapture(event.pointerId); 399 1104 } 400 1105 401 1106 graphNodeDragStateRef.current = null; 402 1107 1108 + if (isSpacePressed) { 1109 + return; 1110 + } 1111 + 1112 + if (!dragState || dragState.pointerId !== event.pointerId) { 1113 + if (isAdditiveSelection) { 1114 + setSelectedNodeIds((current) => 1115 + current.includes(node.blockId) 1116 + ? current.filter((blockId) => blockId !== node.blockId) 1117 + : [...current, node.blockId], 1118 + ); 1119 + } 1120 + return; 1121 + } 1122 + 403 1123 if (dragState.moved) { 404 - suppressNodeClickRef.current = dragState.blockId; 1124 + suppressNodeClickRef.current = node.blockId; 1125 + return; 1126 + } 1127 + 1128 + if (isAdditiveSelection) { 1129 + setSelectedNodeIds((current) => 1130 + current.includes(node.blockId) 1131 + ? current.filter((blockId) => blockId !== node.blockId) 1132 + : [...current, node.blockId], 1133 + ); 405 1134 return; 406 1135 } 407 1136 408 - onSelectBlock(dragState.blockId); 1137 + setSelectedNodeIds([node.blockId]); 1138 + onSelectBlock(node.blockId); 409 1139 } 410 1140 411 1141 function handleGraphNodeClick(blockId: string) { ··· 414 1144 return; 415 1145 } 416 1146 417 - onSelectBlock(blockId); 418 - } 419 - 420 - function handleGraphWheel(event: ReactWheelEvent<HTMLDivElement>) { 421 - const container = graphScrollRef.current; 422 - 423 - if (!container) { 1147 + if (isSpacePressed) { 424 1148 return; 425 1149 } 426 1150 427 - const nextScrollLeft = container.scrollLeft + event.deltaX; 428 - const nextScrollTop = container.scrollTop + event.deltaY; 429 - const canScrollHorizontally = 430 - container.scrollWidth > container.clientWidth && event.deltaX !== 0; 431 - const canScrollVertically = 432 - container.scrollHeight > container.clientHeight && event.deltaY !== 0; 1151 + setSelectedNodeIds([blockId]); 1152 + onSelectBlock(blockId); 1153 + } 433 1154 434 - if (!canScrollHorizontally && !canScrollVertically) { 1155 + function handleImmersiveInspectorResizeStart( 1156 + event: ReactPointerEvent<HTMLDivElement>, 1157 + ) { 1158 + if (!immersive) { 435 1159 return; 436 1160 } 437 1161 438 1162 event.preventDefault(); 439 - 440 - if (canScrollHorizontally) { 441 - container.scrollLeft = nextScrollLeft; 442 - } 443 - 444 - if (canScrollVertically) { 445 - container.scrollTop = nextScrollTop; 446 - } 447 - 448 - syncGraphViewport(); 1163 + event.stopPropagation(); 1164 + immersiveInspectorResizeStateRef.current = { 1165 + startClientX: event.clientX, 1166 + startWidth: immersiveInspectorWidth, 1167 + }; 1168 + setIsResizingImmersiveInspector(true); 449 1169 } 450 1170 1171 + const inspectorContent = 1172 + !selectedBlockId || !blockDraft ? ( 1173 + <div> 1174 + <h3 className="font-display text-3xl text-[var(--ink)]"> 1175 + {t("builder.graphSelectionTitle")} 1176 + </h3> 1177 + <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 1178 + {t("builder.graphSelectionDescription")} 1179 + </p> 1180 + </div> 1181 + ) : ( 1182 + <BlockEditorPanel 1183 + allBlocks={allBlocks} 1184 + blockDraft={blockDraft} 1185 + selectedBlockIcon={selectedBlockIcon} 1186 + choiceOptionsDraft={choiceOptionsDraft} 1187 + branchRulesDraft={branchRulesDraft} 1188 + branchValidationIssues={branchValidationIssues} 1189 + setChoiceOptionsDraft={setChoiceOptionsDraft} 1190 + setBranchRulesDraft={setBranchRulesDraft} 1191 + setBlockDraft={setBlockDraft} 1192 + deleteBlock={deleteBlock} 1193 + saveBlock={() => { 1194 + void saveBlock(); 1195 + }} 1196 + busy={busy} 1197 + /> 1198 + ); 1199 + 451 1200 const minimapWidth = 192; 452 1201 const minimapHeight = 128; 453 1202 const minimapScale = Math.min( 454 - minimapWidth / Math.max(graph.width, 1), 455 - minimapHeight / Math.max(graph.height, 1), 1203 + minimapWidth / Math.max(renderedGraphWidth, 1), 1204 + minimapHeight / Math.max(renderedGraphHeight, 1), 456 1205 ); 457 - const minimapContentWidth = graph.width * minimapScale; 458 - const minimapContentHeight = graph.height * minimapScale; 1206 + const minimapContentWidth = renderedGraphWidth * minimapScale; 1207 + const minimapContentHeight = renderedGraphHeight * minimapScale; 459 1208 const minimapOffsetX = (minimapWidth - minimapContentWidth) / 2; 460 1209 const minimapOffsetY = (minimapHeight - minimapContentHeight) / 2; 461 1210 462 1211 return ( 463 - <div className="space-y-5"> 464 - <Card className="relative overflow-hidden bg-[var(--bg-strong)] p-0"> 1212 + <div> 1213 + <Card 1214 + className={cn( 1215 + "relative overflow-hidden bg-[var(--bg-strong)] p-0", 1216 + immersive && 1217 + "h-full min-h-[calc(100dvh-5.5rem)] rounded-none border-x-0 border-y-0 bg-transparent shadow-none", 1218 + )} 1219 + > 465 1220 {graph.nodes.length ? ( 466 1221 <div 467 1222 ref={graphScrollRef} 468 - className="relative overflow-x-auto overflow-y-auto p-4 lg:max-h-[calc(100vh-18rem)]" 1223 + className={cn( 1224 + "relative overflow-hidden overscroll-x-none", 1225 + immersive 1226 + ? "h-full min-h-[calc(100dvh-5.5rem)] bg-transparent" 1227 + : "h-[420px] lg:h-[calc(100vh-18rem)]", 1228 + )} 469 1229 onPointerDown={handleGraphPointerDown} 470 1230 onPointerMove={handleGraphPointerMove} 471 1231 onPointerUp={handleGraphPointerEnd} 472 1232 onPointerCancel={handleGraphPointerEnd} 473 - onWheel={handleGraphWheel} 474 - onScroll={syncGraphViewport} 475 1233 style={{ 476 - cursor: isPanningGraph ? "grabbing" : "grab", 1234 + cursor: isPanningGraph 1235 + ? "grabbing" 1236 + : isSpacePressed 1237 + ? "grab" 1238 + : selectionRect 1239 + ? "crosshair" 1240 + : "default", 1241 + overscrollBehavior: "contain", 477 1242 }} 478 1243 > 479 1244 <div 480 - className="relative" 1245 + ref={graphSceneRef} 1246 + className="absolute left-0 top-0 origin-top-left will-change-transform" 481 1247 style={{ 482 - width: `${graph.width}px`, 483 - height: `${graph.height}px`, 1248 + width: `${renderedGraphWidth}px`, 1249 + height: `${renderedGraphHeight}px`, 1250 + transform: `translate(0px, 0px) scale(${DEFAULT_GRAPH_ZOOM})`, 484 1251 }} 485 1252 > 486 1253 <svg ··· 495 1262 return null; 496 1263 } 497 1264 498 - const isSelected = selectedGraphEdgeId === edge.id; 499 - const isActiveFromBlock = 500 - !isSelected && selectedBlockId === edge.sourceBlockId; 1265 + const paddedSourceNode = { 1266 + ...sourceNode, 1267 + x: sourceNode.x, 1268 + y: sourceNode.y, 1269 + }; 1270 + const paddedTargetNode = { 1271 + ...targetNode, 1272 + x: targetNode.x, 1273 + y: targetNode.y, 1274 + }; 1275 + const isActiveFromBlock = selectedNodeIdSet.has( 1276 + edge.sourceBlockId, 1277 + ); 501 1278 502 1279 return ( 503 1280 <path 504 1281 key={edge.id} 505 - d={getEdgePath(sourceNode, targetNode)} 1282 + d={getEdgePath(paddedSourceNode, paddedTargetNode)} 506 1283 fill="none" 507 1284 stroke={ 508 - isSelected 509 - ? "var(--accent)" 510 - : isActiveFromBlock 511 - ? "color-mix(in srgb, var(--accent) 65%, var(--line-strong))" 512 - : "color-mix(in srgb, var(--line-strong) 80%, transparent)" 1285 + isActiveFromBlock 1286 + ? "color-mix(in srgb, var(--accent) 65%, var(--line-strong))" 1287 + : "color-mix(in srgb, var(--line-strong) 80%, transparent)" 513 1288 } 514 - strokeWidth={isSelected ? 3 : 2} 1289 + strokeWidth={isActiveFromBlock ? 3 : 2} 515 1290 strokeDasharray={ 516 1291 edge.kind === "linear" ? undefined : "8 6" 517 1292 } 518 - opacity={isSelected || isActiveFromBlock ? 1 : 0.72} 1293 + opacity={isActiveFromBlock ? 1 : 0.72} 519 1294 /> 520 1295 ); 521 1296 })} ··· 538 1313 return null; 539 1314 } 540 1315 1316 + const paddedSourceNode = { 1317 + ...sourceNode, 1318 + x: sourceNode.x, 1319 + y: sourceNode.y, 1320 + }; 1321 + const paddedTargetNode = { 1322 + ...targetNode, 1323 + x: targetNode.x, 1324 + y: targetNode.y, 1325 + }; 1326 + 541 1327 const labelPosition = getEdgeLabelPosition( 542 - sourceNode, 543 - targetNode, 1328 + paddedSourceNode, 1329 + paddedTargetNode, 544 1330 ); 545 - const isSelected = selectedGraphEdgeId === edge.id; 1331 + const isActiveFromBlock = selectedNodeIdSet.has( 1332 + edge.sourceBlockId, 1333 + ); 546 1334 547 1335 return ( 548 - <button 1336 + <div 549 1337 key={`${edge.id}-label`} 550 - type="button" 551 1338 className={cn( 552 - "absolute -translate-x-1/2 -translate-y-1/2 rounded-full border px-3 py-1 text-xs font-medium shadow-[var(--shadow-card)] transition", 553 - isSelected 1339 + "pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full border px-3 py-1 text-xs font-medium shadow-[var(--shadow-card)]", 1340 + isActiveFromBlock 554 1341 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)]" 555 - : "border-[color:var(--line)] bg-[var(--bg-strong)] text-[var(--muted)] hover:border-[var(--line-strong)] hover:text-[var(--ink)]", 1342 + : "border-[color:var(--line)] bg-[var(--bg-strong)] text-[var(--muted)]", 556 1343 )} 557 1344 style={{ 558 1345 left: `${labelPosition.x}px`, 559 1346 top: `${labelPosition.y}px`, 560 1347 }} 561 - onClick={() => onSelectEdge(edge.id, edge.sourceBlockId)} 562 1348 > 563 1349 {edgeLabel} 564 - </button> 1350 + </div> 565 1351 ); 566 1352 })} 567 1353 1354 + {selectionRect ? ( 1355 + <div 1356 + className="pointer-events-none absolute border border-[var(--accent)] bg-[color:color-mix(in_srgb,var(--accent-soft)_55%,transparent)]" 1357 + style={{ 1358 + left: `${Math.min(selectionRect.startX, selectionRect.currentX)}px`, 1359 + top: `${Math.min(selectionRect.startY, selectionRect.currentY)}px`, 1360 + width: `${Math.abs(selectionRect.currentX - selectionRect.startX)}px`, 1361 + height: `${Math.abs(selectionRect.currentY - selectionRect.startY)}px`, 1362 + }} 1363 + /> 1364 + ) : null} 1365 + 568 1366 {positionedNodes.map((node) => { 569 - const isSelected = selectedBlockId === node.blockId; 1367 + const isSelected = selectedNodeIdSet.has(node.blockId); 570 1368 571 1369 return ( 572 1370 <button ··· 575 1373 className={cn( 576 1374 "absolute flex items-start justify-start rounded-[24px] border p-4 text-left transition", 577 1375 isSelected 578 - ? "border-[var(--accent)] bg-[var(--accent-soft)] shadow-[var(--shadow-elevated)]" 579 - : "border-[color:var(--line)] bg-[var(--bg-start)] hover:border-[var(--line-strong)] hover:bg-[var(--bg)]", 1376 + ? immersive 1377 + ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)] ring-1 ring-[color:color-mix(in_srgb,var(--accent)_28%,transparent)]" 1378 + : "border-[var(--accent)] bg-[var(--accent-soft)] shadow-[var(--shadow-elevated)]" 1379 + : immersive 1380 + ? "border-[color:var(--line)] bg-[var(--surface-strong)] shadow-[var(--shadow-surface)] hover:border-[var(--line-strong)] hover:bg-[var(--surface)]" 1381 + : "border-[color:var(--line)] bg-[var(--bg-start)] hover:border-[var(--line-strong)] hover:bg-[var(--bg)]", 580 1382 )} 581 1383 style={{ 582 1384 left: `${node.x}px`, ··· 588 1390 handleGraphNodePointerDown(event, node) 589 1391 } 590 1392 onPointerMove={handleGraphNodePointerMove} 591 - onPointerUp={handleGraphNodePointerUp} 592 - onPointerCancel={handleGraphNodePointerUp} 1393 + onPointerUp={(event) => 1394 + handleGraphNodePointerUp(event, node) 1395 + } 1396 + onPointerCancel={(event) => 1397 + handleGraphNodePointerUp(event, node) 1398 + } 593 1399 onClick={() => handleGraphNodeClick(node.blockId)} 594 1400 > 595 1401 <div className="w-full self-start"> ··· 621 1427 </div> 622 1428 )} 623 1429 1430 + {overlay ? ( 1431 + <div className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6"> 1432 + {overlay} 1433 + </div> 1434 + ) : null} 1435 + 624 1436 {graph.nodes.length ? ( 625 - <div className="pointer-events-none absolute bottom-4 right-4 z-10"> 626 - <div className="overflow-hidden rounded-[18px] border border-[color:color-mix(in_srgb,var(--line)_45%,transparent)] shadow-[var(--shadow-surface)] backdrop-blur-sm"> 1437 + <div 1438 + className={cn( 1439 + "pointer-events-none absolute right-4 z-10", 1440 + immersive ? "bottom-4 sm:bottom-4" : "bottom-4", 1441 + )} 1442 + > 1443 + <div className="overflow-hidden rounded-[18px] border border-[color:var(--line)] bg-[var(--surface)] shadow-[var(--shadow-surface)]"> 627 1444 <svg 628 1445 aria-hidden="true" 629 1446 width={minimapWidth} ··· 637 1454 width={minimapWidth} 638 1455 height={minimapHeight} 639 1456 rx="14" 640 - fill="color-mix(in srgb, var(--bg-end) 92%, transparent)" 1457 + fill="var(--surface)" 641 1458 /> 642 1459 {graph.edges.map((edge) => { 643 1460 const sourceNode = nodesById.get(edge.sourceBlockId); ··· 678 1495 ); 679 1496 })} 680 1497 {positionedNodes.map((node) => { 681 - const isSelected = selectedBlockId === node.blockId; 1498 + const isSelected = selectedNodeIdSet.has(node.blockId); 682 1499 683 1500 return ( 684 1501 <rect ··· 714 1531 ) : null} 715 1532 </Card> 716 1533 717 - <Card className="bg-[var(--bg-strong)] p-6"> 718 - {!selectedBlockId || !blockDraft ? ( 719 - <div> 720 - <h3 className="font-display text-3xl text-[var(--ink)]"> 721 - {t("builder.graphSelectionTitle")} 722 - </h3> 723 - <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 724 - {t("builder.graphSelectionDescription")} 725 - </p> 726 - </div> 727 - ) : !isQuestionBlock(blockDraft.type) ? ( 728 - <div className="space-y-6"> 729 - <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 730 - <div> 731 - <h3 className="font-display text-3xl text-[var(--ink)]"> 732 - {getBlockDisplayLabel(blockDraft)} 733 - </h3> 734 - {blockDraft.description ? ( 735 - <AuthoredMarkdown 736 - content={blockDraft.description} 737 - className="mt-4 max-w-2xl text-sm leading-7 text-[var(--muted)]" 738 - /> 739 - ) : null} 740 - <p className="mt-4 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 741 - {t("builder.graphTextBlockDescription")} 742 - </p> 1534 + {hideInspector ? null : immersive ? ( 1535 + immersiveInspectorOpen ? ( 1536 + <aside 1537 + className="fixed bottom-4 left-4 top-4 z-20 overflow-hidden rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)] sm:bottom-4 sm:left-4 sm:top-4" 1538 + style={{ width: `${immersiveInspectorWidth}px` }} 1539 + > 1540 + <div 1541 + role="separator" 1542 + aria-orientation="vertical" 1543 + aria-label="Resize block settings panel" 1544 + className={cn( 1545 + "absolute inset-y-0 right-0 z-10 w-1 cursor-ew-resize touch-none", 1546 + isResizingImmersiveInspector && "bg-[var(--accent-soft)]", 1547 + )} 1548 + onPointerDown={handleImmersiveInspectorResizeStart} 1549 + /> 1550 + <div className="flex h-full flex-col overflow-hidden pr-1"> 1551 + <div className="border-b border-[color:var(--line)] px-5 py-4"> 1552 + <div className="flex items-center gap-4"> 1553 + <Button 1554 + size="icon" 1555 + variant="secondary" 1556 + onClick={onCloseImmersiveInspector} 1557 + aria-label="Close block settings" 1558 + title="Close block settings" 1559 + > 1560 + <PanelLeftClose className="size-4" /> 1561 + </Button> 1562 + <div className="min-w-0 flex-1"> 1563 + {immersiveFormTitle ? ( 1564 + <p className="truncate font-display text-2xl text-[var(--ink)]"> 1565 + {immersiveFormTitle} 1566 + </p> 1567 + ) : null} 1568 + </div> 1569 + </div> 743 1570 </div> 744 - <Button 745 - onClick={() => void saveBlock()} 746 - disabled={isSavingSelectedBlock} 747 - > 748 - {isSavingSelectedBlock ? ( 749 - <LoaderCircle className="size-4 animate-spin" /> 750 - ) : null} 751 - {t("builder.saveBlock")} 752 - </Button> 753 - </div> 754 - 755 - <BranchRulesEditor 756 - allBlocks={allBlocks} 757 - blockDraft={blockDraft} 758 - branchRulesDraft={branchRulesDraft} 759 - branchValidationIssues={branchValidationIssues} 760 - setBranchRulesDraft={setBranchRulesDraft} 761 - updateConfig={updateConfig} 762 - /> 763 - </div> 764 - ) : ( 765 - <div className="space-y-6"> 766 - <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 767 - <div> 768 - <h3 className="font-display text-3xl text-[var(--ink)]"> 769 - {getBlockDisplayLabel(blockDraft)} 770 - </h3> 771 - {blockDraft.description ? ( 772 - <AuthoredMarkdown 773 - content={blockDraft.description} 774 - className="mt-4 max-w-2xl text-sm leading-7 text-[var(--muted)]" 775 - /> 776 - ) : null} 777 - {selectedEdge && selectedEdgeTarget ? ( 778 - <p className="mt-4 text-sm leading-6 text-[var(--muted)]"> 779 - {t("builder.graphSelectedRoute", { 780 - route: 781 - getGraphEdgeLabel(t, blockDraft, selectedEdge) ?? 782 - t("builder.graphDefaultRoute"), 783 - target: getBlockDisplayLabel(selectedEdgeTarget), 784 - })} 785 - </p> 786 - ) : null} 1571 + <div className="flex-1 overflow-y-auto p-5"> 1572 + {inspectorContent} 787 1573 </div> 788 - <Button 789 - onClick={() => void saveBlock()} 790 - disabled={isSavingSelectedBlock} 791 - > 792 - {isSavingSelectedBlock ? ( 793 - <LoaderCircle className="size-4 animate-spin" /> 794 - ) : null} 795 - {t("builder.saveBlock")} 796 - </Button> 797 1574 </div> 798 - 799 - <BranchRulesEditor 800 - allBlocks={allBlocks} 801 - blockDraft={blockDraft} 802 - branchRulesDraft={branchRulesDraft} 803 - branchValidationIssues={branchValidationIssues} 804 - setBranchRulesDraft={setBranchRulesDraft} 805 - updateConfig={updateConfig} 806 - /> 807 - </div> 808 - )} 809 - </Card> 1575 + </aside> 1576 + ) : null 1577 + ) : ( 1578 + <Card className="bg-[var(--bg-strong)] p-6">{inspectorContent}</Card> 1579 + )} 810 1580 </div> 811 1581 ); 812 1582 }
+1 -8
components/form-builder-panels.tsx
··· 578 578 {SelectedBlockIcon ? ( 579 579 <SelectedBlockIcon className="size-6 text-[var(--ink)]" /> 580 580 ) : null} 581 - <h2 className="font-display text-4xl text-[var(--ink)]"> 581 + <h2 className="font-display text-2xl text-[var(--ink)]"> 582 582 {t(blockTypeTranslationKeys[blockDraft.type])} 583 583 </h2> 584 584 </div> ··· 715 715 /> 716 716 </label> 717 717 ) : null} 718 - 719 - <p className="text-xs text-[var(--muted)]"> 720 - {t("builder.regexHelp")} 721 - </p> 722 718 </div> 723 719 </> 724 720 )} ··· 862 858 </div> 863 859 </SortableContext> 864 860 </DndContext> 865 - <p className="text-xs text-[var(--muted)]"> 866 - {t("builder.optionsHelp")} 867 - </p> 868 861 </div> 869 862 )} 870 863
+149 -3
components/form-builder.test.tsx
··· 6 6 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 7 7 import { installTestDom } from "@/test/install-dom"; 8 8 9 + const routerPushCalls: string[] = []; 9 10 const router = { 10 - push: () => {}, 11 + push: (href: string) => { 12 + routerPushCalls.push(href); 13 + }, 11 14 refresh: () => {}, 12 15 }; 13 16 const { mock } = (await import("bun:test")) as unknown as { ··· 62 65 }; 63 66 } 64 67 65 - function renderBuilder(initialForm: BuilderForm) { 68 + function renderBuilder( 69 + initialForm: BuilderForm, 70 + initialMode?: "standard" | "graph", 71 + ) { 66 72 return render( 67 73 <I18nProvider locale="en" messages={{}}> 68 - <FormBuilder initialForm={initialForm} /> 74 + <FormBuilder initialForm={initialForm} initialMode={initialMode} /> 69 75 </I18nProvider>, 70 76 ); 71 77 } ··· 77 83 } 78 84 79 85 describe("FormBuilder", () => { 86 + routerPushCalls.length = 0; 87 + 80 88 test("switches blocks immediately when there are no unsaved edits", async () => { 81 89 const restoreDom = installTestDom(); 82 90 const firstBlock = createBlock({ ··· 300 308 }); 301 309 302 310 test("opens graph mode and hides the standard builder sidebar", async () => { 311 + routerPushCalls.length = 0; 303 312 const restoreDom = installTestDom(); 304 313 const firstBlock = createBlock({ 305 314 id: "block-1", ··· 321 330 ).toBe(true); 322 331 }); 323 332 expect(view.queryByText("builder.blocks")).toBe(null); 333 + expect(routerPushCalls.at(-1)).toBe("/forms/form-1/graph"); 334 + 335 + await cleanupAndRestore(restoreDom); 336 + }); 337 + 338 + test("renders graph mode directly when mounted on the graph route", async () => { 339 + const restoreDom = installTestDom(); 340 + const firstBlock = createBlock({ 341 + id: "block-1", 342 + type: "SHORT_TEXT", 343 + position: 0, 344 + title: "First question", 345 + }); 346 + 347 + const view = renderBuilder(createForm([firstBlock]), "graph"); 348 + 349 + await waitFor(() => { 350 + expect( 351 + view.getByRole("button", { name: "builder.exitGraphMode" }) !== null, 352 + ).toBe(true); 353 + }); 354 + expect(view.queryByText("builder.blocks")).toBe(null); 324 355 325 356 await cleanupAndRestore(restoreDom); 326 357 }); 327 358 328 359 test("returns to the standard builder layout when graph mode closes", async () => { 360 + routerPushCalls.length = 0; 329 361 const restoreDom = installTestDom(); 330 362 const firstBlock = createBlock({ 331 363 id: "block-1", ··· 352 384 353 385 await waitFor(() => { 354 386 expect(view.getByText("builder.blocks") !== null).toBe(true); 387 + }); 388 + expect(routerPushCalls.at(-1)).toBe("/forms/form-1/edit"); 389 + 390 + await cleanupAndRestore(restoreDom); 391 + }); 392 + 393 + test("restores a persisted graph layout on first mount", async () => { 394 + const restoreDom = installTestDom(); 395 + const firstBlock = createBlock({ 396 + id: "block-1", 397 + type: "SHORT_TEXT", 398 + position: 0, 399 + title: "First question", 400 + }); 401 + const secondBlock = createBlock({ 402 + id: "block-2", 403 + type: "SHORT_TEXT", 404 + position: 1, 405 + title: "Second question", 406 + }); 407 + 408 + window.localStorage.setItem( 409 + "lively-forms:branching-graph-layout:form-1", 410 + JSON.stringify({ "block-1": { x: 164, y: 132 } }), 411 + ); 412 + 413 + const view = renderBuilder(createForm([firstBlock, secondBlock])); 414 + 415 + fireEvent.click( 416 + view.getByRole("button", { name: "builder.openGraphMode" }), 417 + ); 418 + 419 + const restoredGraphNode = await view.findByRole("button", { 420 + name: "blocks.types.SHORT_TEXT First question", 421 + }); 422 + 423 + await waitFor(() => { 424 + expect(restoredGraphNode.style.left).toBe("164px"); 425 + expect(restoredGraphNode.style.top).toBe("132px"); 426 + }); 427 + 428 + await cleanupAndRestore(restoreDom); 429 + }); 430 + 431 + test("persists dragged graph node positions across remounts", async () => { 432 + const restoreDom = installTestDom(); 433 + const firstBlock = createBlock({ 434 + id: "block-1", 435 + type: "SHORT_TEXT", 436 + position: 0, 437 + title: "First question", 438 + }); 439 + const secondBlock = createBlock({ 440 + id: "block-2", 441 + type: "SHORT_TEXT", 442 + position: 1, 443 + title: "Second question", 444 + }); 445 + 446 + const initialForm = createForm([firstBlock, secondBlock]); 447 + const firstView = renderBuilder(initialForm); 448 + 449 + fireEvent.click( 450 + firstView.getByRole("button", { name: "builder.openGraphMode" }), 451 + ); 452 + 453 + const graphNode = await firstView.findByRole("button", { 454 + name: "blocks.types.SHORT_TEXT First question", 455 + }); 456 + 457 + expect(graphNode.style.left).toBe("48px"); 458 + expect(graphNode.style.top).toBe("48px"); 459 + 460 + fireEvent.pointerDown(graphNode, { 461 + button: 0, 462 + pointerId: 1, 463 + clientX: 120, 464 + clientY: 120, 465 + }); 466 + fireEvent.pointerMove(graphNode, { 467 + pointerId: 1, 468 + clientX: 200, 469 + clientY: 180, 470 + }); 471 + fireEvent.pointerUp(graphNode, { 472 + pointerId: 1, 473 + clientX: 200, 474 + clientY: 180, 475 + }); 476 + 477 + await waitFor(() => { 478 + expect(graphNode.style.left).toBe("128px"); 479 + expect(graphNode.style.top).toBe("108px"); 480 + }); 481 + 482 + expect( 483 + window.localStorage.getItem("lively-forms:branching-graph-layout:form-1"), 484 + ).toContain('"block-1":{"x":128,"y":108}'); 485 + 486 + cleanup(); 487 + 488 + const secondView = renderBuilder(initialForm); 489 + 490 + fireEvent.click( 491 + secondView.getByRole("button", { name: "builder.openGraphMode" }), 492 + ); 493 + 494 + const restoredGraphNode = await secondView.findByRole("button", { 495 + name: "blocks.types.SHORT_TEXT First question", 496 + }); 497 + 498 + await waitFor(() => { 499 + expect(restoredGraphNode.style.left).toBe("128px"); 500 + expect(restoredGraphNode.style.top).toBe("108px"); 355 501 }); 356 502 357 503 await cleanupAndRestore(restoreDom);
+89 -33
components/form-builder.tsx
··· 24 24 Hash, 25 25 Link2, 26 26 LoaderCircle, 27 + PanelRight, 27 28 Plus, 28 29 Radio, 29 30 Rows3, ··· 249 250 ); 250 251 } 251 252 252 - export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { 253 + export function FormBuilder({ 254 + initialForm, 255 + initialMode = "standard", 256 + immersiveGraphPage = false, 257 + }: { 258 + initialForm: BuilderForm; 259 + initialMode?: BuilderMode; 260 + immersiveGraphPage?: boolean; 261 + }) { 253 262 const { t } = useI18n(); 254 263 const router = useRouter(); 255 264 const [form, setForm] = useState(initialForm); 256 - const [builderMode, setBuilderMode] = useState<BuilderMode>("standard"); 265 + const [builderMode, setBuilderMode] = useState<BuilderMode>(initialMode); 257 266 const [selection, setSelection] = useState<Selection>(() => 258 267 initialForm.blocks[0] 259 268 ? { kind: "block", blockId: initialForm.blocks[0].id } ··· 267 276 const { toasts, showToast, dismissToast } = useLocalToasts(); 268 277 const [busy, setBusy] = useState<string | null>(null); 269 278 const [isDndReady, setIsDndReady] = useState(false); 279 + const [isImmersiveSidebarOpen, setIsImmersiveSidebarOpen] = useState(false); 270 280 271 281 const [metadataDraft, setMetadataDraft] = useState<FormMetadataDraft>({ 272 282 title: initialForm.title, ··· 344 354 ); 345 355 346 356 useEffect(() => { 357 + setBuilderMode(initialMode); 358 + }, [initialMode]); 359 + 360 + useEffect(() => { 361 + if (!immersiveGraphPage || builderMode !== "graph") { 362 + return; 363 + } 364 + 365 + if (graphSelectedBlockId) { 366 + setIsImmersiveSidebarOpen(true); 367 + } 368 + }, [builderMode, graphSelectedBlockId, immersiveGraphPage]); 369 + 370 + useEffect(() => { 347 371 setMetadataDraft({ 348 372 title: form.title, 349 373 completionTitle: form.completionTitle, ··· 560 584 setBuilderMode("graph"); 561 585 setHasExplicitGraphSelection(false); 562 586 setSelectedGraphEdgeId(null); 587 + router.push(`/forms/${form.id}/graph`); 563 588 } 564 589 565 590 function exitGraphMode() { 566 591 setBuilderMode("standard"); 567 592 setHasExplicitGraphSelection(false); 568 593 setSelectedGraphEdgeId(null); 594 + router.push(`/forms/${form.id}/edit`); 569 595 } 570 596 571 597 function openSettings() { ··· 590 616 }); 591 617 } 592 618 593 - function selectGraphEdge(edgeId: string, blockId: string) { 594 - if (selection.kind === "block" && selection.blockId === blockId) { 595 - setHasExplicitGraphSelection(true); 596 - setSelectedGraphEdgeId(edgeId); 597 - return; 598 - } 599 - 600 - requestPendingNavigation({ 601 - kind: "block", 602 - blockId, 603 - graphEdgeId: edgeId, 604 - activateGraphSelection: true, 605 - }); 606 - } 607 - 608 619 async function confirmPendingNavigationWithSave() { 609 620 if (!pendingNavigation) { 610 621 return; ··· 742 753 form.workspace.kind === "personal" 743 754 ? t("workspace.personal") 744 755 : form.workspace.label; 756 + const isImmersiveGraphPage = immersiveGraphPage && builderMode === "graph"; 745 757 746 758 return ( 747 759 <> ··· 795 807 </div> 796 808 } 797 809 /> 798 - <div className="space-y-6"> 810 + <div 811 + className={cn( 812 + "space-y-6", 813 + isImmersiveGraphPage && 814 + "relative left-1/2 right-1/2 -mt-8 -mb-8 h-[calc(100dvh-5.5rem)] w-screen -translate-x-1/2 overflow-hidden space-y-0", 815 + )} 816 + > 799 817 {builderMode === "graph" ? ( 800 - <PageHeader 801 - title={form.title} 802 - metadata={workspaceLabel} 803 - actions={ 804 - <Button variant="secondary" onClick={exitGraphMode}> 805 - <ArrowLeft className="size-4" /> 806 - {t("builder.exitGraphMode")} 807 - </Button> 808 - } 809 - /> 818 + isImmersiveGraphPage ? null : ( 819 + <PageHeader 820 + title={form.title} 821 + metadata={workspaceLabel} 822 + actions={ 823 + <Button variant="secondary" onClick={exitGraphMode}> 824 + <ArrowLeft className="size-4" /> 825 + {t("builder.exitGraphMode")} 826 + </Button> 827 + } 828 + /> 829 + ) 810 830 ) : ( 811 831 <BuilderHeader 812 832 form={form} ··· 820 840 /> 821 841 )} 822 842 823 - {branchingAnalysis.blockers.length ? ( 843 + {!isImmersiveGraphPage && branchingAnalysis.blockers.length ? ( 824 844 <div className="rounded-[18px] border border-amber-500/30 bg-amber-500/10 px-4 py-4 text-sm text-[var(--ink)]"> 825 845 <p className="font-medium"> 826 846 {t("builder.branchingBlockersTitle", { ··· 848 868 </div> 849 869 ) : null} 850 870 851 - {branchingAnalysis.warnings.length ? ( 871 + {!isImmersiveGraphPage && branchingAnalysis.warnings.length ? ( 852 872 <div className="rounded-[18px] border border-sky-500/30 bg-sky-500/10 px-4 py-4 text-sm text-[var(--ink)]"> 853 873 <p className="font-medium"> 854 874 {t("builder.branchingWarningsTitle", { ··· 878 898 879 899 {builderMode === "graph" ? ( 880 900 <BranchingGraphWorkspace 901 + formId={form.id} 881 902 allBlocks={form.blocks} 882 903 selectedBlockId={graphSelectedBlockId} 883 904 selectedGraphEdgeId={selectedGraphEdgeId} ··· 887 908 ...branchingAnalysis.blockers, 888 909 ...branchingAnalysis.warnings, 889 910 ]} 911 + selectedBlockIcon={SelectedBlockIcon} 912 + choiceOptionsDraft={choiceOptionsDraft} 913 + setChoiceOptionsDraft={setChoiceOptionsDraft} 890 914 setBlockDraft={setBlockDraft} 891 915 setBranchRulesDraft={setBranchRulesDraft} 916 + deleteBlock={deleteBlock} 892 917 saveBlock={saveBlock} 893 918 busy={busy} 894 919 onSelectBlock={selectGraphBlock} 895 - onSelectEdge={selectGraphEdge} 920 + immersive={isImmersiveGraphPage} 921 + hideInspector={false} 922 + immersiveInspectorOpen={ 923 + isImmersiveGraphPage && isImmersiveSidebarOpen 924 + } 925 + immersiveFormTitle={form.title} 926 + onCloseImmersiveInspector={() => setIsImmersiveSidebarOpen(false)} 927 + overlay={ 928 + isImmersiveGraphPage ? ( 929 + <> 930 + <div className="fixed left-4 top-4 z-30 sm:left-4 sm:top-4"> 931 + <Button 932 + size="icon" 933 + variant="secondary" 934 + aria-label="Toggle block settings" 935 + title="Toggle block settings" 936 + onClick={() => 937 + setIsImmersiveSidebarOpen((current) => !current) 938 + } 939 + > 940 + <PanelRight className="size-4" /> 941 + </Button> 942 + </div> 943 + <div className="fixed z-30 sm:right-4 sm:top-4"> 944 + <Button variant="secondary" onClick={exitGraphMode}> 945 + <ArrowLeft className="size-4" /> 946 + {t("builder.exitGraphMode")} 947 + </Button> 948 + </div> 949 + </> 950 + ) : undefined 951 + } 896 952 /> 897 953 ) : ( 898 954 <div className="grid gap-8 lg:min-h-[calc(100vh-14rem)] lg:grid-cols-[300px_minmax(0,1fr)]"> 899 - <aside className="border-b border-[color:var(--line)] pb-6 lg:sticky lg:top-8 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6"> 955 + <aside className="border-b border-[color:var(--line)] pb-6 lg:sticky lg:top-8 lg:flex lg:max-h-[calc(100dvh-8rem)] lg:self-start lg:flex-col lg:overflow-hidden lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6"> 900 956 <div className="flex items-center justify-between gap-3"> 901 957 <h2 className="font-display text-3xl text-[var(--ink)]"> 902 958 {t("builder.blocks")} ··· 961 1017 </div> 962 1018 </div> 963 1019 964 - <div className="mt-5 lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto lg:pr-2"> 1020 + <div className="mt-5 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-2"> 965 1021 <div className="space-y-1.5"> 966 1022 {isDndReady ? ( 967 1023 <DndContext
+5
components/site-footer.tsx
··· 16 16 export function SiteFooter({ copy }: { copy: SiteFooterCopy }) { 17 17 const pathname = usePathname(); 18 18 const showPoweredBy = pathname?.startsWith("/f/"); 19 + const hideFooter = /^\/forms\/[^/]+\/graph$/.test(pathname ?? ""); 20 + 21 + if (hideFooter) { 22 + return null; 23 + } 19 24 20 25 return ( 21 26 <footer>
+1 -1
locales/en.yml
··· 140 140 graphEmptyDescription: Add blocks to start laying out the form flow. 141 141 graphSelectionEyebrow: Routing 142 142 graphSelectionTitle: Select a block to edit its routes 143 - graphSelectionDescription: Click a node or route in the graph to inspect its branching settings. 143 + graphSelectionDescription: Click a block in the graph to inspect its branching settings. 144 144 graphTextBlockDescription: Text blocks appear in the flow, but only question blocks can define branching routes. 145 145 graphSelectedRoute: "Selected route: {route} → {target}" 146 146 graphEditDescription: Review the selected block's default path and branch rules.
+1 -1
locales/ru.yml
··· 140 140 graphEmptyDescription: Добавьте блоки, чтобы построить маршрут формы. 141 141 graphSelectionEyebrow: Маршрутизация 142 142 graphSelectionTitle: Выберите блок, чтобы редактировать его маршруты 143 - graphSelectionDescription: Нажмите на блок или маршрут на графе, чтобы посмотреть и изменить настройки ветвления. 143 + graphSelectionDescription: Нажмите на блок на графе, чтобы посмотреть и изменить настройки ветвления. 144 144 graphTextBlockDescription: Текстовые блоки показываются в потоке, но задавать маршруты могут только блоки с ответом. 145 145 graphSelectedRoute: "Выбранный маршрут: {route} → {target}" 146 146 graphEditDescription: Проверьте маршрут по умолчанию и правила ветвления для выбранного блока.