diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ffd98e9..6738e76 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -134,7 +134,7 @@ Reusable React UI components. - `userDetailsPopup.tsx` - User information popup - `sidebar.css` - Sidebar styles - **Element defaults vs. selected-shape edits:** `src/utils/applyProperty.ts` (`createApplyProperty`) is the single mutation path behind `elementProperties.tsx`. Every property change (1) updates the matching default via `useElementDefaults` setters, then (2) if a shape is selected, applies the same change to that shape. So editing a property with nothing selected just sets the default; editing with a shape selected sets both. Defaults store `null` for `strokeType: 'solid'` (matching what `primary.tsx` feeds new shapes); DB rows store the literal `'solid'`/`'dashed'`/`'dotted'`. + **Element defaults vs. selected-shape edits:** `src/utils/applyProperty.ts` (`createApplyProperty`) is the single mutation path behind `elementProperties.tsx`. Every property change (1) updates the matching default via `useElementDefaults` setters, then (2) if a shape is selected, applies the same change to that shape. So editing a property with nothing selected just sets the default; editing with a shape selected sets both. Defaults store `null` for `strokeType: 'solid'` (matching what `primary.tsx` feeds new shapes); DB rows store the literal `'solid'`/`'dashed'`/`'dotted'`. - **`common/`**: Shared utility components - `button.tsx` - Base button component @@ -302,7 +302,39 @@ See detailed notes in `.claude/context/` for feature-specific implementation det - `.claude/context/floating-toolbar.md` - Floating toolbar activation and structure - `.claude/context/undo-history.md` - Undo/history stack: action entry shapes, `recordToHistoryLog`, and `undoLastAction()` as the canonical rollback for any failed mutation - `.claude/context/responsive-design.md` - When to use Tailwind responsive prefixes vs `useMediaQueryUtils` hook; breakpoint values for both; the core decision rule -- `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat (canvas sketch); CSS variables, Tailwind config, and usage rules per area +- `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat Brush (canvas sketch); CSS variables, Tailwind config, and usage rules per area +- `claude/context/reorder.md` - How reording/positioning of elements in Z-Axis (Z-order) works in craftbase + +## Port connectors (connectable arrows) + +Connectors are `arrowLine` elements whose tail/head can dock onto a shape's +edge **port**. + +- **Port** — a connection point floated just outside each edge midpoint + (n/e/s/w) of a `rectangle` selection box. Rendered + hit-tested in + `src/canvas/selectionController.ts`; geometry in `src/utils/shapePorts.ts` + (`getShapePortPoint`). Clicking a port pulls out a connector whose tail is + pinned there (`startPortConnector` in `src/newCanvas.tsx`). +- **Nearby-port radar** — while an arrow endpoint is being dragged, the cursor + is the probe: `findNearestPort` (`shapePorts.ts`) finds the closest port in + range (`PORT_RADAR_RADIUS`), which the controller highlights with the amber + pulsing `portGlow` ring + the dashed `nearbyPortExpectedShape` skeleton around + the candidate shape. A **one-off magnetic snap** glues the endpoint to that + port; pulling past the threshold releases it (never forced). On release while + docked, the binding is committed (`updatePortRadar`/`applyPendingPortConnection`). +- **Binding columns** — attachment is stored as 4 fields on the arrow row: + `tailShapeId`/`tailEdge` and `headShapeId`/`headEdge` (`*Edge` = `n/e/s/w-resize`). + Reverse lookup is derived by scanning the store (no shape-side columns). + `reanchorArrowsForShape`/`persistBoundArrows` keep a docked endpoint glued when + the bound shape moves/resizes. + +**Persisted-mode caveat:** these 4 fields currently live only on Two.js +`elementData` + the local/localStorage store — they are **not** columns in the +Hasura `components` table or `src/schema/generated.ts`. So bindings work in +**local mode (`/`)** but do **not** survive a reload on a **saved board +(`/board/:id`)** yet. Enabling persisted mode needs: `ALTER TABLE` to add the 4 +nullable columns + track them in Hasura, `yarn codegen`, and adding them to the +board-load query so they read back. ### Component schema (from DB) diff --git a/.claude/context/reorder.md b/.claude/context/reorder.md new file mode 100644 index 0000000..cf7122c --- /dev/null +++ b/.claude/context/reorder.md @@ -0,0 +1,26 @@ +## Reorder/Positioning of elements + +In 2d space, we need to adjust how elements behave on z-axis. For that we need to reorder children of two group to achieve such expectations. + +In craftbase, we have four options + +- Bring to front (Brings it to the foremost top of the order, at [N]) +- Bring forward (Brings 1 order up , at[current+1]) +- Send Backward (Sends 1 order down, at [current-1]) +- Send to Back (Sends to last of the order, at [0]) + +This is being triggered by three inputs from user + +- Keyboard shortcuts +- Context Menu (opens on right click) +- Element Properties Toolbar or edit toolbar + +Shortcuts are + +``` +`]` = Bring Forward, `[` = Send Backward, `⌘` + `]` = Bring to Front, `⌘` + `[` = Send to Back (and Ctrl+… on Windows/Linux) +``` + +## Business logic + +We attach a property to each component called `position` which determines its position in Z-Axis or two's scene. The core logic is implemented at `reorderSelected` fn of newCanvas.tsx file . diff --git a/README.md b/README.md index f15328a..33f8a66 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Craftbase -A minimal whiteboard you can open and start drawing on. No signup, no setup, no empty-state tutorial to click through — the canvas is just there waiting. +A minimal online whiteboard you can open and start drawing on. No signup, no setup, no empty-state tutorial to click through — the canvas is just there waiting. **Try it: [craftbase.org](https://craftbase.org)** diff --git a/VERSION b/VERSION index 11d9d6c..f83dbb3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.7 \ No newline at end of file +0.7.8 \ No newline at end of file diff --git a/index.html b/index.html index 57f528f..3041554 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ Craftbase - minimal whiteboard for builders diff --git a/package.json b/package.json index 499f324..7c898a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbase", - "version": "0.7.7", + "version": "0.7.8", "private": true, "main": "src/lib.ts", "module": "src/lib.ts", diff --git a/public/robots.txt b/public/robots.txt index e9e57dc..f7353e9 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,3 +1,5 @@ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: + +Sitemap: https://craftbase.org/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..7293bc8 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,33 @@ + + + + https://craftbase.org/ + 2026-06-14 + weekly + 1.0 + + + https://craftbase.org/home + 2026-06-14 + monthly + 0.8 + + + https://craftbase.org/embeddable-whiteboard + 2026-06-14 + monthly + 0.9 + + + https://craftbase.org/support + 2026-06-14 + monthly + 0.5 + + + https://craftbase.org/privacy + 2026-06-14 + yearly + 0.3 + + diff --git a/src/App.css b/src/App.css index 2909780..7a8a9b2 100644 --- a/src/App.css +++ b/src/App.css @@ -1,8 +1,9 @@ :root { --font-ui: 'Geist', system-ui, sans-serif; --font-display: 'Fraunces', Georgia, serif; - --font-sketch: 'Caveat', cursive; + --font-sketch: 'Caveat Brush'; --font-mono: 'Geist Mono', monospace; + --font-caveat-brush: 'Caveat Brush', cursive; --color-canvas: #f5f0e8; --color-sidebar: #ede8dc; @@ -18,6 +19,12 @@ --color-border-card: #c4b89a; } +.caveat-brush-regular { + font-family: 'Caveat Brush', cursive; + font-weight: 400; + font-style: normal; +} + body { margin: 0; font-family: var(--font-ui); diff --git a/src/App.tsx b/src/App.tsx index 17e8c1d..29e6ae4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import BoardViewContainer from './views/Board' import HomePageViewContainer from './views/Home' import SupportViewContainer from './views/Support' import PrivacyViewContainer from './views/Privacy' +import EmbeddableViewContainer from './views/Embeddable' import CraftbaseLoader from './components/common/craftbaseLoader' import routes from './routes' @@ -171,6 +172,10 @@ class App extends Component { path={routes.privacy} element={} /> + } + /> diff --git a/src/assets/bring-forward.svg b/src/assets/bring-forward.svg new file mode 100644 index 0000000..91e16ef --- /dev/null +++ b/src/assets/bring-forward.svg @@ -0,0 +1 @@ + Bring Forward 1 \ No newline at end of file diff --git a/src/assets/bring-to-front.svg b/src/assets/bring-to-front.svg new file mode 100644 index 0000000..b778cd3 --- /dev/null +++ b/src/assets/bring-to-front.svg @@ -0,0 +1 @@ + Bring to Front N \ No newline at end of file diff --git a/src/assets/cards.svg b/src/assets/cards.svg new file mode 100644 index 0000000..24f4cff --- /dev/null +++ b/src/assets/cards.svg @@ -0,0 +1 @@ + Cards \ No newline at end of file diff --git a/src/assets/chevron-right.svg b/src/assets/chevron-right.svg new file mode 100644 index 0000000..eb7b08e --- /dev/null +++ b/src/assets/chevron-right.svg @@ -0,0 +1 @@ + Right \ No newline at end of file diff --git a/src/assets/layers.svg b/src/assets/layers.svg new file mode 100644 index 0000000..beb513b --- /dev/null +++ b/src/assets/layers.svg @@ -0,0 +1 @@ + Layers \ No newline at end of file diff --git a/src/assets/send-backward.svg b/src/assets/send-backward.svg new file mode 100644 index 0000000..38bc83e --- /dev/null +++ b/src/assets/send-backward.svg @@ -0,0 +1 @@ + Send Backward 1 \ No newline at end of file diff --git a/src/assets/send-to-back.svg b/src/assets/send-to-back.svg new file mode 100644 index 0000000..e080ffe --- /dev/null +++ b/src/assets/send-to-back.svg @@ -0,0 +1 @@ + Send to Back N \ No newline at end of file diff --git a/src/assets/settings.svg b/src/assets/settings.svg new file mode 100644 index 0000000..e72174e --- /dev/null +++ b/src/assets/settings.svg @@ -0,0 +1 @@ + Settings \ No newline at end of file diff --git a/src/canvas/selectionController.ts b/src/canvas/selectionController.ts index 7b92044..ee1412f 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -4,11 +4,16 @@ import { getShapeTextNodes, renderShapeTextLayer, shapeTextStyleFromMeta, + syncTextHitRect, } from '../utils/canvasUtils' +import { reflowTextForShape, minShapeWidthForText } from '../utils/shapeTextFit' import { - reflowTextForShape, - minShapeWidthForText, -} from '../utils/shapeTextFit' + lineHeightFor, + measureTextWidth, + type FontSpec, +} from '../utils/textLayout' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' +import { getConnectorsEnabled } from '../utils/featureFlags' // Two.js scene shapes carry codebase-specific bookkeeping (elementData, // _renderer, etc.) outside the published types. Stay loose here; Stage 12 @@ -28,6 +33,43 @@ interface ShapeAdapter { resizable: boolean minWidth: number minHeight: number + // 'dimension' (default) → corner drag changes width/height. 'font' → corner + // drag scales the font size of a standalone text block (no w/h change). The + // box still tracks the rendered block via getLocalSize. + resizeMode?: 'dimension' | 'font' +} + +// Font spec for a single standalone text line node. +function textNodeFontSpec(node: ShapeLike): FontSpec { + return { + family: node?.family || DEFAULT_TEXT_FONT_FAMILY, + size: node?.size || 36, + weight: node?.weight, + } +} + +// Surface-unit size of a standalone text block: widest measured line × the +// stacked line height. measureTextWidth returns surface units (same space as a +// shape's width), so this feeds the selection box directly — no screen↔surface +// conversion needed. +function textBlockLocalSize(group: ShapeLike): { + width: number + height: number +} { + const nodes = getShapeTextNodes(group) + if (!nodes.length) return { width: 60, height: 36 } + const size = nodes[0]?.size || 36 + let maxW = 0 + nodes.forEach((nd) => { + maxW = Math.max( + maxW, + measureTextWidth(nd?.value || '', textNodeFontSpec(nd)) + ) + }) + return { + width: Math.max(maxW, 20), + height: Math.max(nodes.length * lineHeightFor(size), size), + } } const DEFAULT_ADAPTER: ShapeAdapter = { @@ -44,14 +86,13 @@ const DEFAULT_ADAPTER: ShapeAdapter = { minHeight: 20, } -// eslint-disable-next-line @typescript-eslint/no-unused-vars const TEXT_ADAPTER: ShapeAdapter = { - getLocalSize: (shape) => ({ - width: shape.getBoundingClientRect(true).width || 60, - height: shape.getBoundingClientRect(true).height || 30, - }), - applySize: () => {}, - resizable: false, + // currentShape is line 1 (group.children[0]); walk up to the group to size + // the whole multiline block. + getLocalSize: (shape) => textBlockLocalSize(shape?.parent ?? shape), + applySize: () => {}, // sizing happens via font scaling, not w/h + resizable: true, + resizeMode: 'font', minWidth: 20, minHeight: 20, } @@ -60,6 +101,7 @@ const SHAPE_ADAPTERS: Record = { rectangle: DEFAULT_ADAPTER, circle: DEFAULT_ADAPTER, diamond: DEFAULT_ADAPTER, + newText: TEXT_ADAPTER, } // Handle dot diameter in *screen* px, stepped across 3 zoom ranges so the dots @@ -72,7 +114,30 @@ function handleScreenPx(scale: number): number { const HANDLE_HIT_SLOP_MOUSE = 3 const HANDLE_HIT_SLOP_TOUCH = 8 const MIN_SCALE_DIMENSION = 20 -const SELECTION_PADDING = 5 +// Surface-unit padding between the shape edge and the selection box border. +// Exported so connector anchoring matches where the box (and thus ports) sit. +export const SELECTION_PADDING = 5 +// Screen-px gap between the selection box border and the connection ports. +// Exported so connector anchoring (newCanvas) can pin tails to the same spot. +export const PORT_GAP = 10 +// Screen-px radius within which a dragging connector head "snaps" a nearby +// port into its radar and lights its glow. Exported so newCanvas can run the +// same proximity test the glow is keyed off. +export const PORT_RADAR_RADIUS = 26 + +// --- Port "radar" glow (amber pulsing ring shown over a landable port) --- +// Base radius (screen px) of the glow; the glow group counter-scales to zoom. +const GLOW_BASE_RADIUS = 9 +// One expand-and-fade ping every this many ms. +const GLOW_PERIOD_MS = 1100 +// Amber palette for the glow. +const GLOW_RING_COLOR = '#E0A22B' +const GLOW_CORE_COLOR = '#F2C150' + +// Selection box + resize-handle stroke. Theme `ink` (#1A1612) — the warm +// near-black primary ink, for strong contrast against the parchment `canvas` +// (#F5F0E8). The muted `accent.dark` gold used previously blended into the bg. +const SELECTION_STROKE = '#1A1612' interface ToolbarState { element: Record @@ -119,10 +184,22 @@ interface SelectionControllerOptions { onDeselect?: () => void commit?: ( id: string, - patch: { width: number; height: number; x: number; y: number } + patch: { + width: number + height: number + x: number + y: number + // Font resize on text also carries updated metadata (fontSize + + // multiline content). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: Record + } ) => void recordHistory?: () => void onDelete?: (group: GroupLike) => void + // Fired live while the selection is scaled/rotated so the host can drag + // connectors bound to the shape's ports along with the transform. + onTransform?: (group: GroupLike) => void } interface HitResult { @@ -143,6 +220,8 @@ interface ScaleInteraction { initialHeight: number initialPosition: { x: number; y: number } initialRotation: number + // Font size at gesture start — only used for 'font' resizeMode (text). + initialFontSize?: number } interface RotateInteraction { @@ -162,7 +241,12 @@ export default class SelectionController { callbacks: Required< Pick< SelectionControllerOptions, - 'onSelect' | 'onDeselect' | 'commit' | 'recordHistory' | 'onDelete' + | 'onSelect' + | 'onDeselect' + | 'commit' + | 'recordHistory' + | 'onDelete' + | 'onTransform' > > @@ -180,8 +264,31 @@ export default class SelectionController { ui!: ShapeLike box!: ShapeLike endpoints!: ShapeLike - midEndpoints!: ShapeLike - midPoints!: ShapeLike[] + portHandles!: ShapeLike + portPoints!: ShapeLike[] + + // Connection ports sit at each edge midpoint, floated outside the selection + // box. Hovering a port reveals this outward-pointing arrow — the affordance + // for "open a path to connect". Ports are NOT resize handles. + portArrow!: ShapeLike + + // Radar glow shown over the nearest landable port while a connector is being + // drawn. Lives at scene level (not inside `ui`) so it survives `detach()`, + // which fires when a connector pulls out of the source shape. + portGlow!: ShapeLike + private _glowRing!: ShapeLike + private _glowCore!: ShapeLike + private _glowRaf: number | null = null + private _glowStart = 0 + + // Dashed amber skeleton drawn around the shape whose port is the current + // radar target — signals "the connector will attach to THIS shape". Shown + // and hidden together with `portGlow`. + nearbyPortExpectedShape!: ShapeLike + + private _halfW = 0 + private _halfH = 0 + private _hoveredPort: string | null = null private _onUpdate: (() => void) | null = null private _onClearSelector: (() => void) | null = null @@ -199,6 +306,7 @@ export default class SelectionController { commit, recordHistory, onDelete, + onTransform, }: SelectionControllerOptions) { this.two = two this.zui = zui @@ -209,6 +317,7 @@ export default class SelectionController { commit: commit ?? ((): void => {}), recordHistory: recordHistory ?? ((): void => {}), onDelete: onDelete ?? ((): void => {}), + onTransform: onTransform ?? ((): void => {}), } this._buildUi() @@ -223,17 +332,17 @@ export default class SelectionController { // eslint-disable-next-line @typescript-eslint/no-explicit-any const box = new (Two as any).Rectangle(0, 0, 0, 0) box.noFill() - box.stroke = '#C4901A' + box.stroke = SELECTION_STROKE box.linewidth = 1.5 // eslint-disable-next-line @typescript-eslint/no-explicit-any const endpoints = new (Two as any).Points(box.vertices) endpoints.size = 10 endpoints.fill = '#FFFCF5' - endpoints.stroke = '#C4901A' + endpoints.stroke = SELECTION_STROKE endpoints.linewidth = 1.5 - this.midPoints = [ + this.portPoints = [ // eslint-disable-next-line @typescript-eslint/no-explicit-any new (Two as any).Vector(0, 0), // n // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -244,20 +353,114 @@ export default class SelectionController { new (Two as any).Vector(0, 0), // w ] // eslint-disable-next-line @typescript-eslint/no-explicit-any - const midEndpoints = new (Two as any).Points(this.midPoints) - midEndpoints.size = 10 - midEndpoints.fill = '#FFFCF5' - midEndpoints.stroke = '#C4901A' - midEndpoints.linewidth = 1.5 - midEndpoints.visible = false - - ui.add(box, endpoints, midEndpoints) + const portHandles = new (Two as any).Points(this.portPoints) + portHandles.size = 10 + portHandles.fill = '#8C7E6A' + // portHandles.stroke = '#C4901A' + portHandles.linewidth = 0 + portHandles.visible = false + + const portArrow = this._buildPortArrow() + + ui.add(box, endpoints, portHandles, portArrow) this.two.add(ui) + // Glow + target skeleton live at scene level (not in `ui`) so they can + // highlight any shape's port — including while the source shape's + // selection is detached mid-draw. The skeleton is added before the glow + // so the glow dot renders on top of the outline. + const nearbyPortExpectedShape = this._buildNearbyPortShape() + this.two.add(nearbyPortExpectedShape) + const portGlow = this._buildPortGlow() + this.two.add(portGlow) + this.ui = ui this.box = box this.endpoints = endpoints - this.midEndpoints = midEndpoints + this.portHandles = portHandles + this.portArrow = portArrow + this.portGlow = portGlow + this.nearbyPortExpectedShape = nearbyPortExpectedShape + } + + // A dashed amber rectangle outline (no fill) that wraps the radar-target + // shape. Positioned/sized per-frame by `_showNearbyPortShape` to match the + // candidate shape's bounds; counter-scaled so the stroke stays crisp. + private _buildNearbyPortShape(): ShapeLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rect = new (Two as any).Rectangle(0, 0, 0, 0) + rect.noFill() + rect.stroke = GLOW_RING_COLOR + rect.linewidth = 1.5 + rect.dashes = [6, 4] + rect.visible = false + return rect + } + + // An amber "radar ping": a steady translucent core plus an expanding ring + // that the animation loop grows and fades. Built in glow-local space and + // counter-scaled to the zoom so it stays a constant screen size. + private _buildPortGlow(): ShapeLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Circle = (Two as any).Circle + + const core = new Circle(0, 0, GLOW_BASE_RADIUS) + core.fill = GLOW_CORE_COLOR + core.opacity = 0.3 + core.noStroke() + + const ring = new Circle(0, 0, GLOW_BASE_RADIUS) + ring.noFill() + ring.stroke = GLOW_RING_COLOR + ring.linewidth = 2 + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = new (Two as any).Group() + g.add(core, ring) + g.visible = false + + this._glowCore = core + this._glowRing = ring + return g + } + + // A small outward-pointing arrow icon (shaft + chevron head) drawn in + // box-local space, growing along +x from the origin. It lives as a child of + // `ui`, so it inherits the selection's translation/rotation; per-sync we set + // `scale = 1/zoom` so it stays a constant screen size. Positioned/rotated by + // `_positionPortArrow` to sit just outside whichever edge is hovered. + private _buildPortArrow(): ShapeLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Anchor = (Two as any).Anchor + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shaft = new (Two as any).Path( + [new Anchor(0, 0), new Anchor(16, 0)], + false, + false + ) + shaft.noFill() + shaft.stroke = '#C4901A' + shaft.linewidth = 1.5 + shaft.cap = 'round' + shaft.join = 'round' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const head = new (Two as any).Path( + [new Anchor(12, -4), new Anchor(16, 0), new Anchor(12, 4)], + false, + false + ) + head.noFill() + head.stroke = '#C4901A' + head.linewidth = 1.5 + head.cap = 'round' + head.join = 'round' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = new (Two as any).Group() + g.add(shaft, head) + g.visible = false + return g } private _bindExternal(): void { @@ -292,7 +495,12 @@ export default class SelectionController { window.removeEventListener('keydown', this._onKeyDown, false) } this._detachPointerStream() + this._stopGlowAnim() this.two.remove(this.ui) + if (this.portGlow) this.two.remove(this.portGlow) + if (this.nearbyPortExpectedShape) { + this.two.remove(this.nearbyPortExpectedShape) + } } canHandle(group: GroupLike): boolean { @@ -313,6 +521,17 @@ export default class SelectionController { } } + /** + * Public re-assert of the selection overlay to the top of the scene. + * Called by the z-order reconcile after it re-sorts element groups, so the + * selection box never gets buried beneath a just-reordered element. No-op + * when nothing is selected. + */ + bringSelectionToFront(): void { + if (!this.currentGroup) return + this._bringToFront() + } + attach(group: GroupLike, shape?: ShapeLike): boolean { const type = group?.elementData?.componentType const adapter = SHAPE_ADAPTERS[type] @@ -355,6 +574,8 @@ export default class SelectionController { this.currentAdapter = null this.currentTextChild = null this.currentTextLayer = null + this._hoveredPort = null + if (this.portArrow) this.portArrow.visible = false this.ui.visible = false this.domElement.classList.remove('shape-selected') this.domElement.style.cursor = '' @@ -380,7 +601,7 @@ export default class SelectionController { // directly — no /scale — else the dots inflate when zoomed out. const handleSize = handleScreenPx(scale) this.endpoints.size = handleSize - this.midEndpoints.size = handleSize + this.portHandles.size = handleSize const { width, height } = this.currentAdapter.getLocalSize( this.currentShape @@ -389,22 +610,217 @@ export default class SelectionController { this.box.width = width + pad * 2 this.box.height = height + pad * 2 + // Standalone text is anchored left/middle at the group origin (the text + // extends RIGHT from translation.x and is vertically centered on + // translation.y). The box is centered on `ui`, so shift `ui` right by + // half the block width to wrap the text instead of sitting left of it. + // Shapes are centered on their origin, so no offset. + const anchorOffsetX = + this.currentAdapter?.resizeMode === 'font' ? width / 2 : 0 this.ui.position.set( - this.currentGroup.translation.x, + this.currentGroup.translation.x + anchorOffsetX, this.currentGroup.translation.y ) this.ui.rotation = this.currentGroup.rotation || 0 const isRect = this.currentGroup?.elementData?.componentType === 'rectangle' - this.midEndpoints.visible = isRect - if (isRect) { + // Ports only render when the connectors feature flag is on (live). + const portsOn = isRect && getConnectorsEnabled() + this.portHandles.visible = portsOn + if (portsOn) { const hw = (width + pad * 2) / 2 const hh = (height + pad * 2) / 2 - this.midPoints[0]!.set(0, -hh) - this.midPoints[1]!.set(hw, 0) - this.midPoints[2]!.set(0, hh) - this.midPoints[3]!.set(-hw, 0) + this._halfW = hw + this._halfH = hh + // Float the connection ports outside the selection box. + const portOff = PORT_GAP / scale + this.portPoints[0]!.set(0, -hh - portOff) + this.portPoints[1]!.set(hw + portOff, 0) + this.portPoints[2]!.set(0, hh + portOff) + this.portPoints[3]!.set(-hw - portOff, 0) + // Keep a visible port arrow glued to the (possibly resized/zoomed) + // edge. + if (this._hoveredPort && this.portArrow.visible) { + this._positionPortArrow(this._hoveredPort) + } + } + } + + // Place + orient the port arrow just outside `edge` and counter-scale it to + // a constant screen size. + private _positionPortArrow(edge: string): void { + if (!this.portArrow) return + const scale = this.zui.scale || 1 + // Sit beyond the port (port gap + port radius + a small margin). + const gap = (PORT_GAP + handleScreenPx(scale) / 2 + 6) / scale + const hw = this._halfW + const hh = this._halfH + let x = 0 + let y = 0 + let rot = 0 + switch (edge) { + case 'e-resize': + x = hw + gap + rot = 0 + break + case 's-resize': + y = hh + gap + rot = Math.PI / 2 + break + case 'w-resize': + x = -hw - gap + rot = Math.PI + break + case 'n-resize': + y = -hh - gap + rot = -Math.PI / 2 + break + default: + return + } + this.portArrow.position.set(x, y) + this.portArrow.rotation = rot + this.portArrow.scale = 1 / scale + } + + // Toggle the hover arrow for `edge` (or hide it when null). Only repaints on + // an actual change so it's cheap to call from the mousemove hover stream. + private _setHoveredPort(edge: string | null): void { + if (this._hoveredPort === edge) return + this._hoveredPort = edge + if (edge) { + this._positionPortArrow(edge) + this.portArrow.visible = true + } else { + this.portArrow.visible = false + } + this.two.update() + } + + // ---------- Port radar glow ---------- + + // Light (or move) the radar glow over a landable port at `surface` (its + // floated anchor, in surface coords). Idempotent per position; starts the + // pulse loop the first time it becomes visible. Pass `targetGroup` (the + // shape that owns the port) to also draw the dashed skeleton around it. Call + // `hidePortGlow` once the head leaves every port's range. + showPortGlow( + surface: { x: number; y: number }, + targetGroup?: ShapeLike + ): void { + if (!this.portGlow || !getConnectorsEnabled()) return + const scale = this.zui.scale || 1 + this.portGlow.position.set(surface.x, surface.y) + this.portGlow.scale = 1 / scale + if (targetGroup) this._showNearbyPortShape(targetGroup) + this._bringToSceneFront(this.nearbyPortExpectedShape) + this._bringToSceneFront(this.portGlow) + if (!this.portGlow.visible) { + this.portGlow.visible = true + this._glowStart = + typeof performance !== 'undefined' ? performance.now() : 0 + // Prime a full-opacity frame so the glow appears instantly — the + // previous ping may have stopped mid-fade, leaving the ring at ~0 + // opacity, which would otherwise render blank for one frame. + this._glowRing.radius = GLOW_BASE_RADIUS + this._glowRing.opacity = 1 + this._glowCore.opacity = 0.3 + } + // Always ensure the pulse loop is alive. This is robust to a prior run + // that stopped while `visible` stayed latched, or a swallowed render + // error that left `_glowRaf` dangling — otherwise the glow would light + // exactly once and never animate again. + if (this._glowRaf === null) this._startGlowAnim() + } + + hidePortGlow(): void { + const wasVisible = this.portGlow?.visible + const hadShape = this.nearbyPortExpectedShape?.visible + if (!wasVisible && !hadShape) return + if (this.portGlow) this.portGlow.visible = false + if (this.nearbyPortExpectedShape) { + this.nearbyPortExpectedShape.visible = false + } + this._stopGlowAnim() + this.two.update() + } + + // Wrap the radar-target shape with the dashed skeleton, matching its + // centre, size (+ selection padding) and rotation. Counter-scales the + // stroke/dashes so they stay crisp at any zoom. + private _showNearbyPortShape(group: ShapeLike): void { + const rect = this.nearbyPortExpectedShape + if (!rect) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shape = (group as any)?.children?.[0] + const w = shape?.width ?? group?.elementData?.width ?? 0 + const h = shape?.height ?? group?.elementData?.height ?? 0 + if (!w || !h) { + rect.visible = false + return + } + const scale = this.zui.scale || 1 + const pad = SELECTION_PADDING + rect.width = w + pad * 2 + rect.height = h + pad * 2 + rect.translation.set(group.translation.x, group.translation.y) + rect.rotation = group.rotation || 0 + rect.linewidth = 1.5 / scale + rect.dashes = [6 / scale, 4 / scale] + rect.visible = true + } + + // rAF loop so the ping keeps animating even when the cursor holds still over + // a port. Self-cancels when the glow is hidden. `_glowRaf` is cleared at the + // TOP of each tick so that even if `two.update()` throws (the documented + // scene.subtractions hazard), the loop can be restarted by the next + // `showPortGlow` instead of being wedged forever. + private _startGlowAnim(): void { + const tick = (): void => { + this._glowRaf = null + if (!this.portGlow || !this.portGlow.visible) return + const now = + typeof performance !== 'undefined' ? performance.now() : 0 + const cycles = (now - this._glowStart) / GLOW_PERIOD_MS + const t = cycles - Math.floor(cycles) // 0..1, loops each period + // Expanding ring that fades as it grows — the "ping". + this._glowRing.radius = GLOW_BASE_RADIUS * (1 + t * 1.4) + this._glowRing.opacity = 1 - t + // Core breathes gently so the port stays alive between pings. + this._glowCore.opacity = + 0.24 + 0.12 * (0.5 + 0.5 * Math.sin(cycles * Math.PI * 2)) + try { + this.two.update() + } catch { + // A transient renderer hiccup must not kill the pulse loop. + } + this._glowRaf = requestAnimationFrame(tick) + } + this._glowRaf = requestAnimationFrame(tick) + } + + private _stopGlowAnim(): void { + if (this._glowRaf !== null) { + cancelAnimationFrame(this._glowRaf) + this._glowRaf = null + } + } + + // Push a scene-level overlay element to the top of the draw order so it + // isn't buried by shapes/connectors. Re-adds it if it somehow left the + // scene. Shared by the glow and the nearby-port skeleton. + private _bringToSceneFront(el: ShapeLike): void { + if (!el) return + const scene = this.two.scene + const idx = scene.children.indexOf(el) + if (idx === -1) { + this.two.add(el) + return + } + if (idx !== scene.children.length - 1) { + scene.children.splice(idx, 1) + scene.children.push(el) } } @@ -422,23 +838,89 @@ export default class SelectionController { const hitRadiusPx = handleScreenPx(scale) / 2 + this._hitSlopPx() const hitLimit = hitRadiusPx / scale + // Corners win over edges so the corner-resize/rotate zone isn't shadowed + // by the full-edge hit band at the box ends. + const corner = this._atCorner(surface, hitLimit) + if (corner) { + const isOnInnerRing = this._withinCornerRadius( + surface, + corner, + hitLimit + ) + const mode = + isOnInnerRing && this.rotationEnabled ? 'rotate' : 'scale' + return { mode, corner } + } + const isRect = this.currentGroup?.elementData?.componentType === 'rectangle' if (isRect) { - const midEdge = this._atMidEdge(surface, hitLimit) - if (midEdge) return { mode: 'scale', corner: midEdge } + // Resize lives on the edge band only. Ports are connection points, + // not resize handles, so they're intentionally excluded here. + const edge = this._atEdge(surface, hitLimit) + if (edge) return { mode: 'scale', corner: edge } } - const corner = this._atCorner(surface, hitLimit) - if (!corner) return null + return null + } - const isOnInnerRing = this._withinCornerRadius( - surface, - corner, - hitLimit - ) - const mode = isOnInnerRing && this.rotationEnabled ? 'rotate' : 'scale' - return { mode, corner } + // Public port hit test for the canvas: did this client point land on a + // connection port? Returns the hovered edge plus the port's anchor in + // surface coords (where a pulled-out connector's tail should pin). Null when + // nothing is selected or the cursor isn't over a port. + hitTestPort( + clientX: number, + clientY: number + ): { edge: string; surface: { x: number; y: number } } | null { + if (!this.currentGroup || !this.ui.visible) return null + const surface = this.zui.clientToSurface(clientX, clientY) + const edge = this._hoveredPortEdge(surface) + if (!edge) return null + const edgeToIndex: Record = { + 'n-resize': 0, + 'e-resize': 1, + 's-resize': 2, + 'w-resize': 3, + } + const idx = edgeToIndex[edge] + if (idx === undefined) return null + const anchor = this._vertexToSurface(this.portPoints[idx]!) + return { edge, surface: { x: anchor.x, y: anchor.y } } + } + + // Proximity test against the 4 floated connection ports. Gates the port + // arrow, which must only appear over a port — not along the rest of the edge. + private _atPort( + point: { x: number; y: number }, + limit: number + ): CornerHandle | null { + const sq = limit * limit + const ports: CornerHandle[] = [ + { name: 'n-resize', point: this.portPoints[0]! }, + { name: 'e-resize', point: this.portPoints[1]! }, + { name: 's-resize', point: this.portPoints[2]! }, + { name: 'w-resize', point: this.portPoints[3]! }, + ] + for (const p of ports) { + const surface = this._vertexToSurface(p.point) + if (distSq(point.x, point.y, surface.x, surface.y) < sq) return p + } + return null + } + + // Edge name (n/e/s/w-resize) whose port the surface point is hovering, or + // null. Rectangle-only; this is what the port arrow keys off of. + private _hoveredPortEdge(point: { x: number; y: number }): string | null { + // Single chokepoint for both hover (port arrow) and `hitTestPort` + // (pull-out). Off when connectors are disabled. + if (!getConnectorsEnabled()) return null + if (this.currentGroup?.elementData?.componentType !== 'rectangle') { + return null + } + const scale = this.zui.scale || 1 + const limit = (handleScreenPx(scale) / 2 + this._hitSlopPx()) / scale + const port = this._atPort(point, limit) + return port ? port.name : null } private _vertexToSurface(v: { x: number; y: number }): { @@ -473,20 +955,36 @@ export default class SelectionController { return null } - private _atMidEdge( + // Full-edge hit test: derotate the surface point into box-local space and + // check whether it sits within `limit` of any of the 4 borders (along the + // whole edge, not just its midpoint). Corners are handled by `_atCorner` + // first, so the band ends overlapping corners don't matter here. + private _atEdge( point: { x: number; y: number }, limit: number ): CornerHandle | null { - const sq = limit * limit - const edges: CornerHandle[] = [ - { name: 'n-resize', point: this.midPoints[0]! }, - { name: 'e-resize', point: this.midPoints[1]! }, - { name: 's-resize', point: this.midPoints[2]! }, - { name: 'w-resize', point: this.midPoints[3]! }, - ] - for (const edge of edges) { - const p = this._vertexToSurface(edge.point) - if (distSq(point.x, point.y, p.x, p.y) < sq) return edge + const rot = this.ui.rotation || 0 + const dx = point.x - this.ui.position.x + const dy = point.y - this.ui.position.y + const cos = Math.cos(-rot) + const sin = Math.sin(-rot) + const lx = dx * cos - dy * sin + const ly = dx * sin + dy * cos + const hw = this.box.width / 2 + const hh = this.box.height / 2 + const withinX = lx >= -hw - limit && lx <= hw + limit + const withinY = ly >= -hh - limit && ly <= hh + limit + if (withinX && Math.abs(ly + hh) <= limit) { + return { name: 'n-resize', point: this.portPoints[0]! } + } + if (withinY && Math.abs(lx - hw) <= limit) { + return { name: 'e-resize', point: this.portPoints[1]! } + } + if (withinX && Math.abs(ly - hh) <= limit) { + return { name: 's-resize', point: this.portPoints[2]! } + } + if (withinY && Math.abs(lx + hw) <= limit) { + return { name: 'w-resize', point: this.portPoints[3]! } } return null } @@ -529,6 +1027,8 @@ export default class SelectionController { beginInteraction(e: MouseEvent, hit: HitResult | null): boolean { if (!this.currentGroup || !hit) return false + // The arrow is a hover-only affordance; drop it once a drag begins. + this._setHoveredPort(null) if (hit.mode === 'scale') return this._beginScale(e, hit.corner) if (hit.mode === 'rotate' && this.rotationEnabled) { return this._beginRotate(e, hit.corner) @@ -554,6 +1054,7 @@ export default class SelectionController { y: this.currentGroup.translation.y, }, initialRotation: this.currentGroup.rotation || 0, + initialFontSize: this.currentShape?.size ?? 36, } this._attachPointerStream() return true @@ -601,14 +1102,21 @@ export default class SelectionController { if (this._onHover) return this._onHover = (ev): void => { if (this.interaction) return + const surface = this.zui.clientToSurface(ev.clientX, ev.clientY) + // The arrow is keyed off the port only — not the full-edge resize + // band — so it pulls out solely when hovering the port. + const portEdge = this._hoveredPortEdge(surface) + this._setHoveredPort(portEdge) + if (portEdge) { + // Port hover signals "open a path to connect" — not resize. + this.domElement.style.cursor = 'crosshair' + return + } + const hit = this.hitTest(ev.clientX, ev.clientY) if (!hit) { // Over the shape body → move (4-way arrow drag cue); empty // canvas → default. - const surface = this.zui.clientToSurface( - ev.clientX, - ev.clientY - ) this.domElement.style.cursor = this._withinBody(surface) ? 'move' : '' @@ -647,6 +1155,12 @@ export default class SelectionController { private _scaleMove(e: MouseEvent): void { if (!this.interaction || this.interaction.mode !== 'scale') return if (!this.currentAdapter) return + // Standalone text resizes by font size (anchored at its center), not by + // width/height like shapes. + if (this.currentAdapter.resizeMode === 'font') { + this._fontScaleMove(e) + return + } const { corner, startSurface, @@ -729,9 +1243,7 @@ export default class SelectionController { // just its widest single character (1 char/line) — but no further. const meta = this.currentGroup?.elementData?.metadata const rawText = - meta && typeof meta.textContent === 'string' - ? meta.textContent - : '' + meta && typeof meta.textContent === 'string' ? meta.textContent : '' const hasShapeText = !!this.currentTextLayer && !!meta?.hasText && rawText.length > 0 @@ -763,10 +1275,7 @@ export default class SelectionController { reflow.lines, style ) - } else if ( - Math.abs(newWidth) < minW || - Math.abs(newHeight) < minH - ) { + } else if (Math.abs(newWidth) < minW || Math.abs(newHeight) < minH) { return } @@ -832,6 +1341,53 @@ export default class SelectionController { this.currentGroup.translation.set(nextX, nextY) this.syncToTarget() this.two.update() + this.callbacks.onTransform(this.currentGroup) + } + + // Font-size resize for standalone text: scale the size by how far the + // cursor moved relative to the block's center (mirrors the old per-element + // interactjs handle). Anchored at the center, so the block never translates. + private _fontScaleMove(e: MouseEvent): void { + if (!this.interaction || this.interaction.mode !== 'scale') return + const { startSurface, initialPosition, initialFontSize, initialWidth } = + this.interaction + // Text is anchored left/middle at the group origin, so its visual center + // is offset right by half the block width. Anchor the scaling there + // (mirrors the old per-element resize which keyed off the text center). + const center = { + x: initialPosition.x + (initialWidth ?? 0) / 2, + y: initialPosition.y, + } + const surface = this.zui.clientToSurface(e.clientX, e.clientY) + const startDist = Math.hypot( + startSurface.x - center.x, + startSurface.y - center.y + ) + const curDist = Math.hypot(surface.x - center.x, surface.y - center.y) + const factor = curDist / Math.max(startDist, 1) + const base = initialFontSize ?? 36 + const newSize = Math.round(Math.min(Math.max(base * factor, 8), 300)) + this._applyTextFontSize(this.currentGroup, newSize) + this.syncToTarget() + this.two.update() + this.callbacks.onTransform(this.currentGroup) + } + + // Resize every line node to `size` and re-stack the block at the new line + // height, vertically centered on the group origin. Matches newText's + // syncMultilineLayout so the live scene and a reload render identically. + private _applyTextFontSize(group: ShapeLike, size: number): void { + const nodes = getShapeTextNodes(group) + const n = nodes.length + const lineH = lineHeightFor(size) + nodes.forEach((nd, i) => { + nd.size = size + nd.leading = size + nd.translation.set(0, (i - (n - 1) / 2) * lineH) + }) + // Re-fit the transparent gap hit area to the resized block so the text + // stays selectable across the whole block after a font resize. + syncTextHitRect(this.two, group) } private _rotateMove(e: MouseEvent): void { @@ -850,6 +1406,7 @@ export default class SelectionController { initialRotation + (currentAngle - startAngle) this.syncToTarget() this.two.update() + this.callbacks.onTransform(this.currentGroup) } private _onPointerUp(_e: MouseEvent): void { @@ -867,12 +1424,35 @@ export default class SelectionController { const { width, height } = this.currentAdapter.getLocalSize( this.currentShape ) - const patch = { + const patch: { + width: number + height: number + x: number + y: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: Record + } = { width: parseInt(String(width)), height: parseInt(String(height)), x: parseInt(String(this.currentGroup.translation.x)), y: parseInt(String(this.currentGroup.translation.y)), } + // Font resize (text): persist the new size + the multiline content + // so a reload restores the resized block. + if (this.currentAdapter.resizeMode === 'font') { + const nodes = getShapeTextNodes(this.currentGroup) + const size = this.currentShape?.size + const meta = this.currentGroup?.elementData?.metadata || {} + const newMeta = { + ...meta, + fontSize: size, + content: nodes.map((nd) => nd?.value ?? '').join('\n'), + } + patch.metadata = newMeta + if (this.currentGroup.elementData) { + this.currentGroup.elementData.metadata = newMeta + } + } this.callbacks.commit(componentId, patch) } diff --git a/src/components/canvasContextMenu.tsx b/src/components/canvasContextMenu.tsx new file mode 100644 index 0000000..836b01f --- /dev/null +++ b/src/components/canvasContextMenu.tsx @@ -0,0 +1,260 @@ +import { useEffect, useRef, useState } from 'react' +import type { ReactElement, FunctionComponent, SVGProps } from 'react' + +import Portal from './common/portal' +import { isMac } from '../utils/misc' +import LayersIcon from '../assets/layers.svg?react' +import BringToFrontIcon from '../assets/bring-to-front.svg?react' +import BringForwardIcon from '../assets/bring-forward.svg?react' +import SendBackwardIcon from '../assets/send-backward.svg?react' +import SendToBackIcon from '../assets/send-to-back.svg?react' +import ChevronRightIcon from '../assets/chevron-right.svg?react' + +export type ReorderOp = 'front' | 'forward' | 'backward' | 'back' + +interface CanvasContextMenuProps { + x: number + y: number + onClose: () => void + onExportSvg: () => void + onReorder: (op: ReorderOp) => void +} + +const MENU_WIDTH = 240 +const SUBMENU_WIDTH = 240 +const MENU_MARGIN = 8 + +// Shared icon tone — matches the menu's ink-mid text. The source SVGs hardcode +// a blue stroke; SVGR spreads props after the original attrs, so this wins. +const ICON_STROKE = '#8C7E6A' + +const itemClass = + `w-full flex items-center justify-between px-3 py-2 mx-0 text-sm text-ink-mid ` + + `hover:bg-accent/30 rounded cursor-pointer transition-colors ease-in-out duration-150` + +const shortcutClass = 'text-[10px] text-ink-muted tracking-wide' + +// Format a shortcut for the host OS: compact symbols on mac (⌘]), the +// conventional spelled-out form elsewhere (Ctrl+]). Kept in sync with the +// keyboard handler in newCanvas.tsx, which acts on metaKey on mac / ctrlKey on +// the rest. Reorder uses bare brackets for forward/back-one and ⌘/Ctrl+bracket +// for to-front/to-back (no Shift — ⌘⇧[/] are reserved tab-switch on mac Chrome). +export const fmtShortcut = ( + key: string, + { cmd = false, shift = false }: { cmd?: boolean; shift?: boolean } = {} +): string => + isMac + ? `${cmd ? '⌘' : ''}${shift ? '⇧' : ''}${key}` + : `${cmd ? 'Ctrl+' : ''}${shift ? 'Shift+' : ''}${key}` + +type ReorderItem = { + op: ReorderOp + label: string + shortcut: string + Icon: FunctionComponent> +} + +const REORDER_ITEMS: ReorderItem[] = [ + { + op: 'front', + label: 'Bring to Front', + shortcut: fmtShortcut(']', { cmd: true }), + Icon: BringToFrontIcon, + }, + { + op: 'forward', + label: 'Bring Forward', + shortcut: fmtShortcut(']'), + Icon: BringForwardIcon, + }, + { + op: 'backward', + label: 'Send Backward', + shortcut: fmtShortcut('['), + Icon: SendBackwardIcon, + }, + { + op: 'back', + label: 'Send to Back', + shortcut: fmtShortcut('[', { cmd: true }), + Icon: SendToBackIcon, + }, +] + +/** + * Small fixed-position menu opened on canvas right-click / two-finger tap. + * Closes on outside click or Escape. Positioned at the cursor and clamped to + * the viewport so it never overflows off-screen. The Reorder entry opens a + * flyout submenu to the right (or left, near the screen edge). + */ +const CanvasContextMenu = ({ + x, + y, + onClose, + onExportSvg, + onReorder, +}: CanvasContextMenuProps): ReactElement => { + const refNode = useRef(null) + const [height, setHeight] = useState(0) + const [reorderOpen, setReorderOpen] = useState(false) + + useEffect(() => { + if (refNode.current) setHeight(refNode.current.offsetHeight) + }, []) + + useEffect(() => { + const handleClick = (e: MouseEvent): void => { + if (refNode.current?.contains(e.target as Node)) return + onClose() + } + const handleKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('mousedown', handleClick, false) + document.addEventListener('keydown', handleKey, false) + return (): void => { + document.removeEventListener('mousedown', handleClick, false) + document.removeEventListener('keydown', handleKey, false) + } + }, [onClose]) + + const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_MARGIN) + const top = Math.min(y, window.innerHeight - (height || 60) - MENU_MARGIN) + const clampedLeft = Math.max(MENU_MARGIN, left) + + // Flip the flyout to the left when there isn't room on the right. + const openLeft = + clampedLeft + MENU_WIDTH + SUBMENU_WIDTH + MENU_MARGIN > + window.innerWidth + + return ( + +
+
setReorderOpen(true)} + onMouseLeave={() => setReorderOpen(false)} + > + + + {reorderOpen && ( +
+ {REORDER_ITEMS.map( + ({ op, label, shortcut, Icon }) => ( + + ) + )} +
+ )} +
+ +
+ + +
+ + ) +} + +export default CanvasContextMenu diff --git a/src/components/common/toggleSwitch.tsx b/src/components/common/toggleSwitch.tsx new file mode 100644 index 0000000..4a5eabf --- /dev/null +++ b/src/components/common/toggleSwitch.tsx @@ -0,0 +1,46 @@ +import type { ReactElement } from 'react' + +interface ToggleSwitchProps { + checked: boolean + onChange: (checked: boolean) => void + id?: string + label?: string + disabled?: boolean +} + +// A small accessible on/off switch. Uses the amber `accent` token when on and a +// neutral track when off; the knob slides between the two ends. +const ToggleSwitch = ({ + checked, + onChange, + id, + label, + disabled = false, +}: ToggleSwitchProps): ReactElement => ( + +) + +export default ToggleSwitch diff --git a/src/components/common/tooltip.tsx b/src/components/common/tooltip.tsx new file mode 100644 index 0000000..4279e8b --- /dev/null +++ b/src/components/common/tooltip.tsx @@ -0,0 +1,193 @@ +import { + cloneElement, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react' +import type { + FocusEvent, + MouseEvent, + ReactElement, + ReactNode, + Ref, +} from 'react' + +import Portal from './portal' + +export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right' + +interface TooltipProps { + /** Content shown on hover / keyboard focus. Falsy → tooltip is suppressed. */ + label: ReactNode + /** Exactly one hoverable/focusable trigger element. */ + children: ReactElement + placement?: TooltipPlacement + /** + * ms to wait before showing. Defaults to 0 so the hint appears effectively + * instantly (a short opacity fade still smooths it out). Bump it if you want + * a hover-intent delay. + */ + delay?: number + disabled?: boolean +} + +// Gap in px between the trigger and the tooltip bubble. +const GAP = 8 +// Min distance the bubble keeps from the viewport edges when clamped. +const EDGE_MARGIN = 6 + +interface Pos { + top: number + left: number +} + +/** + * Lightweight, dependency-free tooltip. + * + * - Wraps a SINGLE trigger and shows `label` on hover or keyboard focus. + * - Clones the child (no extra wrapper DOM) so the trigger keeps its slot in + * flex/grid layouts. + * - Renders the bubble through a Portal, so it is never clipped by an ancestor's + * `overflow` (e.g. the scrollable floating toolbar) and positions it with + * `position: fixed` off the trigger's viewport rect, clamped on-screen. + * + * Reuse anywhere a control needs a hint: + * + */ +const Tooltip = ({ + label, + children, + placement = 'top', + delay = 0, + disabled = false, +}: TooltipProps): ReactElement => { + const triggerRef = useRef(null) + const bubbleRef = useRef(null) + const timerRef = useRef | null>(null) + + // `anchor` holds the trigger rect while the tooltip is open; `pos` is the + // measured/clamped bubble position. Two phases avoid an off-screen flash: + // render hidden at the anchor, measure, then place + fade in. + const [anchor, setAnchor] = useState(null) + const [pos, setPos] = useState(null) + + const show = useCallback((): void => { + if (disabled || !label) return + const open = (): void => { + const el = triggerRef.current + if (el) setAnchor(el.getBoundingClientRect()) + } + if (delay <= 0) { + open() + return + } + timerRef.current = setTimeout(open, delay) + }, [delay, disabled, label]) + + const hide = useCallback((): void => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + setAnchor(null) + setPos(null) + }, []) + + useLayoutEffect(() => { + if (!anchor) return + const bubble = bubbleRef.current + if (!bubble) return + const { width: bw, height: bh } = bubble.getBoundingClientRect() + + let top: number + let left: number + switch (placement) { + case 'bottom': + top = anchor.bottom + GAP + left = anchor.left + anchor.width / 2 - bw / 2 + break + case 'left': + top = anchor.top + anchor.height / 2 - bh / 2 + left = anchor.left - GAP - bw + break + case 'right': + top = anchor.top + anchor.height / 2 - bh / 2 + left = anchor.right + GAP + break + case 'top': + default: + top = anchor.top - GAP - bh + left = anchor.left + anchor.width / 2 - bw / 2 + } + + // Keep the bubble fully on-screen. + left = Math.max( + EDGE_MARGIN, + Math.min(left, window.innerWidth - bw - EDGE_MARGIN) + ) + top = Math.max( + EDGE_MARGIN, + Math.min(top, window.innerHeight - bh - EDGE_MARGIN) + ) + setPos({ top, left }) + }, [anchor, placement]) + + // Merge our ref + open/close handlers onto the child, preserving any the + // caller already passed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const child = children as ReactElement & { ref?: Ref } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const childProps: any = child.props + + const trigger = cloneElement(child, { + ref: (node: HTMLElement | null): void => { + triggerRef.current = node + const r = child.ref + if (typeof r === 'function') r(node) + else if (r && typeof r === 'object') + (r as { current: HTMLElement | null }).current = node + }, + onMouseEnter: (e: MouseEvent): void => { + show() + childProps.onMouseEnter?.(e) + }, + onMouseLeave: (e: MouseEvent): void => { + hide() + childProps.onMouseLeave?.(e) + }, + onFocus: (e: FocusEvent): void => { + show() + childProps.onFocus?.(e) + }, + onBlur: (e: FocusEvent): void => { + hide() + childProps.onBlur?.(e) + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + return ( + <> + {trigger} + {anchor && ( + +
+ {label} +
+
+ )} + + ) +} + +export default Tooltip diff --git a/src/components/elements/circle.tsx b/src/components/elements/circle.tsx index ede7799..aaa8d27 100644 --- a/src/components/elements/circle.tsx +++ b/src/components/elements/circle.tsx @@ -34,10 +34,11 @@ function Circle(props: ElementProps): ReactElement { }) const { group, circle } = elementFactory.createElement() group.elementData = { ...props.itemData, ...props } - circle.opacity = props.metadata?.opacity ?? 1 + const opacityValue = props.metadata?.opacity ?? 1 if (props.parentGroup) { const parentGroup = props.parentGroup + circle.opacity = opacityValue circle.translation.x = props.properties.x circle.translation.y = props.properties.y parentGroup.add(circle) @@ -57,6 +58,10 @@ function Circle(props: ElementProps): ReactElement { meta ) + // Group-level opacity so shape + embedded text dim uniformly and + // actually repaint (see rectangle.tsx for the unshift rationale). + group.opacity = opacityValue + two.update() const groupEl = document.getElementById(group.id) diff --git a/src/components/elements/diamond.tsx b/src/components/elements/diamond.tsx index d72dead..0a5529a 100644 --- a/src/components/elements/diamond.tsx +++ b/src/components/elements/diamond.tsx @@ -29,10 +29,11 @@ function Diamond(props: ElementProps): ReactElement { }) const { group, diamond } = elementFactory.createElement() group.elementData = { ...props.itemData, ...props } - diamond.opacity = props.metadata?.opacity ?? 1 + const opacityValue = props.metadata?.opacity ?? 1 if (props.parentGroup) { const parentGroup = props.parentGroup + diamond.opacity = opacityValue parentGroup.add(diamond) two.update() } else { @@ -50,6 +51,10 @@ function Diamond(props: ElementProps): ReactElement { meta ) + // Group-level opacity so shape + embedded text dim uniformly and + // actually repaint (see rectangle.tsx for the unshift rationale). + group.opacity = opacityValue + two.update() const groupEl = document.getElementById(group.id) diff --git a/src/components/elements/geoText.tsx b/src/components/elements/geoText.tsx index a629f60..d72d64a 100644 --- a/src/components/elements/geoText.tsx +++ b/src/components/elements/geoText.tsx @@ -14,7 +14,10 @@ import { import { lineHeightFor } from '../../utils/textLayout' import { useMediaQueryUtils } from '../../constants/exportHooks' import { computeCounterScale } from '../../utils/counterScale' -import { DEFAULT_GEO_RESIST } from '../../constants/misc' +import { + DEFAULT_GEO_RESIST, + DEFAULT_TEXT_FONT_FAMILY, +} from '../../constants/misc' // GeoText is a clone of NewText (it reuses the same NewTextFactory for // rendering) with one extra behavior: like a point pin, the whole group is @@ -367,7 +370,7 @@ function GeoText(props: ElementProps): ReactElement { input.style.padding = `${vertPad}px 8px` input.style.color = twoText.fill || '#3A342C' input.style.fontSize = `${cssFontSize}px` - input.style.fontFamily = twoText.family || 'Caveat' + input.style.fontFamily = twoText.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = twoText.weight || 'normal' input.style.lineHeight = `${lineH}px` input.style.letterSpacing = '0px' @@ -390,7 +393,8 @@ function GeoText(props: ElementProps): ReactElement { measureSpan.style.visibility = 'hidden' measureSpan.style.whiteSpace = 'pre' measureSpan.style.fontSize = `${cssFontSize}px` - measureSpan.style.fontFamily = twoText.family || 'Caveat' + measureSpan.style.fontFamily = + twoText.family || DEFAULT_TEXT_FONT_FAMILY measureSpan.style.fontWeight = twoText.weight || 'normal' measureSpan.style.lineHeight = `${lineH}px` measureSpan.style.letterSpacing = '0px' diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index 2095078..b8488f3 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -8,12 +8,45 @@ import Two from 'two.js' import { useBoardContext } from '../../views/Board/boardContext' import getEditComponents from '../utils/editWrapper' import { elementOnBlurHandler } from '../../utils/misc' +import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' +import { layoutStandaloneText } from '../../utils/canvasUtils' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any // eslint-disable-next-line @typescript-eslint/no-explicit-any type ShapeLike = any +// Child element factories load asynchronously, so group.add() can fire out of +// array order on first grouping. Re-sort the group's children by their stored +// z-order `position` (back→front) after every add so the within-group stacking +// always matches the canvas — independent of load timing. The transparent +// selector rectangle (no elementData) is pinned to the back. group.children +// .sort fires Two.js's 'order' event so the SVG nodes physically reorder. +const orderGroupChildrenByZ = (group: ShapeLike): void => { + group.children.sort((a: ShapeLike, b: ShapeLike) => { + const aHas = !!a?.elementData + const bHas = !!b?.elementData + if (aHas !== bHas) return aHas ? 1 : -1 + const pa = Number.isFinite(a?.elementData?.position) + ? a.elementData.position + : 0 + const pb = Number.isFinite(b?.elementData?.position) + ? b.elementData.position + : 0 + if (pa !== pb) return pa - pb + const ca = Number.isFinite(a?.elementData?.createdAt) + ? a.elementData.createdAt + : 0 + const cb = Number.isFinite(b?.elementData?.createdAt) + ? b.elementData.createdAt + : 0 + if (ca !== cb) return ca - cb + return String(a?.elementData?.id ?? '').localeCompare( + String(b?.elementData?.id ?? '') + ) + }) +} + function GroupedObjectWrapper(props: ElementProps): ReactElement { const { addToLocalComponentStore, @@ -31,6 +64,10 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { useState(null) const [groupId, setGroupId] = useState(null) const isDeletingRef = useRef(false) + // Last group position already written to history. commitGroupMove() compares + // the live translation against this so a move is recorded exactly once, + // whether the commit is triggered by drag-end (mouseup) or blur. + const lastCommitPosRef = useRef<{ x: number; y: number } | null>(null) let groupInstance: ShapeLike = null let selectorInstance: ShapeLike = null @@ -38,149 +75,182 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { return element && two.scene.children.includes(element) } - function onBlurHandler(e: FocusEvent): void { - elementOnBlurHandler(e, selectorInstance, two) + // Tear down just the transient overlay (resize box + member copies) and drop + // the selection state — without touching the store. two.remove defers the + // SVG detach to the next update; if that throws (scene.subtractions pitfall, + // see CLAUDE.md) clear the stuck subtraction so future updates don't keep + // retrying the broken removal. + function dismissOverlayNode(): void { + selectorInstance?.hide?.() window.dispatchEvent(new CustomEvent('groupBlurred')) - if (!isDeletingRef.current) { - const userId = localStorage.getItem('userId') - const childrenIdsOfTheGroup = props.children.map( - (item: ShapeLike) => item.id - ) - - let foundOriginalCount = 0 + try { + two.remove([groupInstance]) + two.update() + } catch (err) { + console.warn('two.update() during group overlay teardown:', err) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const blurBatchEntries: any[] = [] + const scene = two.scene as any + scene.subtractions.length = 0 + scene._flagSubtractions = false + } + } - const initialGroupX = parseInt(String(props.x)) || 0 - const initialGroupY = parseInt(String(props.y)) || 0 - const groupMoved = - Math.abs(groupInstance.translation.x - initialGroupX) > 0.5 || - Math.abs(groupInstance.translation.y - initialGroupY) > 0.5 + // Restore the (group-level) opacity of our member elements — they were + // hidden at 0 under the overlay. metadata may be a pencil vertex array, so + // guard the `.opacity` read. + function revealMembers(): void { + const childrenIds = props.children.map((i: ShapeLike) => i.id) + two.scene.children.forEach((element: ShapeLike) => { + if (!element.elementData) return + if (!childrenIds.includes(element.elementData.id)) return + const elMeta = element.elementData.metadata + element.opacity = + elMeta && !Array.isArray(elMeta) ? (elMeta.opacity ?? 1) : 1 + }) + } - two.scene.children.forEach((element: ShapeLike) => { - if (!element.elementData) return - if (childrenIdsOfTheGroup.includes(element.elementData.id)) { - foundOriginalCount++ - element.opacity = 1 + // Sync the (hidden) member elements to the overlay's current position and + // record the move to history as ONE batch. Idempotent: if the group hasn't + // moved since the last commit (lastCommitPosRef) it's a no-op, so calling it + // on BOTH drag-end (mouseup) and blur never double-records. Committing on + // drag-end is what makes a group move the last history entry — so undo + // reverts the move even while the group is still selected, instead of + // popping the previous action (e.g. a paste). + function commitGroupMove(): void { + if (isDeletingRef.current) return + if (!groupInstance || !isInScene(groupInstance)) return + + const gx = parseInt(String(groupInstance.translation.x)) + const gy = parseInt(String(groupInstance.translation.y)) + const baseline = lastCommitPosRef.current + if ( + baseline && + Math.abs(gx - baseline.x) < 0.5 && + Math.abs(gy - baseline.y) < 0.5 + ) { + return + } - if (!groupMoved) { - return - } + const userId = localStorage.getItem('userId') + const childrenIds = props.children.map((i: ShapeLike) => i.id) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const batchEntries: any[] = [] + + two.scene.children.forEach((element: ShapeLike) => { + if (!element.elementData) return + if (!childrenIds.includes(element.elementData.id)) return - let findRelativeDataForChild: ShapeLike = {} - props.children.forEach((item: ShapeLike) => { - if (item.id === element?.elementData?.id) { - findRelativeDataForChild = item + let relativeData: ShapeLike = {} + props.children.forEach((item: ShapeLike) => { + if (item.id === element.elementData.id) relativeData = item + }) + const newX = gx + parseInt(String(relativeData.x)) + const newY = gy + parseInt(String(relativeData.y)) + element.translation.x = newX + element.translation.y = newY + + let newMetadata = element.elementData.metadata + if ( + element.elementData.componentType === 'pencil' && + Array.isArray(element.elementData.metadata) + ) { + const m0 = element.elementData.metadata[0] + newMetadata = element.elementData.metadata.map( + (vert: ShapeLike, index: number) => { + const lwProp = + vert.lw !== undefined ? { lw: vert.lw } : {} + if (index === 0) { + return { x: newX, y: newY, ...lwProp } } - }) - const newX = - parseInt(String(groupInstance.translation.x)) + - parseInt(String(findRelativeDataForChild.x)) - const newY = - parseInt(String(groupInstance.translation.y)) + - parseInt(String(findRelativeDataForChild.y)) - element.translation.x = newX - element.translation.y = newY - - let newMetadata = element.elementData.metadata - if ( - element.elementData.componentType === 'pencil' && - Array.isArray(element.elementData.metadata) - ) { - newMetadata = element.elementData.metadata.map( - (vert: ShapeLike, index: number) => { - const lwProp = - vert.lw !== undefined - ? { lw: vert.lw } - : {} - if (index === 0) { - return { x: newX, y: newY, ...lwProp } - } - return { - x: - newX + - parseInt( - String( - vert.x - - element.elementData - .metadata[0].x - ) - ), - y: - newY + - parseInt( - String( - vert.y - - element.elementData - .metadata[0].y - ) - ), - ...lwProp, - } - } - ) - element.children.forEach((eachChild: ShapeLike) => { - if (eachChild.vertices) { - eachChild.vertices = [] - newMetadata.forEach(function ( - point: ShapeLike - ) { - eachChild.vertices.push( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (Two as any).Anchor( - point.x - newX, - point.y - newY - ) - ) - }) - } + return { + x: newX + parseInt(String(vert.x - m0.x)), + y: newY + parseInt(String(vert.y - m0.y)), + ...lwProp, + } + } + ) + element.children.forEach((eachChild: ShapeLike) => { + if (eachChild.vertices) { + eachChild.vertices = [] + newMetadata.forEach((point: ShapeLike) => { + eachChild.vertices.push( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (Two as any).Anchor( + point.x - newX, + point.y - newY + ) + ) }) } + }) + } - const childId = element?.elementData?.id - const current = - stateRefForComponentStore?.current?.[childId] ?? {} - const updateObj = { - metadata: newMetadata, - x: element.translation.x, - y: element.translation.y, - updatedBy: userId, - } + const childId = element.elementData.id + const current = (stateRefForComponentStore?.current?.[childId] ?? + {}) as ShapeLike + const updateObj = { + metadata: newMetadata, + x: newX, + y: newY, + updatedBy: userId, + } + const positionChanged = + current.x !== updateObj.x || current.y !== updateObj.y + const metadataChanged = newMetadata !== current.metadata + if (!positionChanged && !metadataChanged) return + + const prevProps = { + metadata: current.metadata, + x: current.x, + y: current.y, + updatedBy: current.updatedBy, + } + updateComponentBulkPropertiesInLocalStore(childId, updateObj, true) + batchEntries.push({ + action: 'UPDATE_BULK', + id: childId, + prevProps, + bulkObj: updateObj, + }) + }) - const c = current as ShapeLike - const positionChanged = - c.x !== updateObj.x || c.y !== updateObj.y - const metadataChanged = newMetadata !== c.metadata + if (batchEntries.length > 0) { + recordBatchToHistoryLog(batchEntries) + } + // Advance the baseline even if nothing recorded, so we don't re-scan on + // every subsequent mouseup at the same position. + lastCommitPosRef.current = { x: gx, y: gy } + two.update() + } - if (!positionChanged && !metadataChanged) { - two.update() - return - } + function onBlurHandler(e: FocusEvent): void { + elementOnBlurHandler(e, selectorInstance, two) + window.dispatchEvent(new CustomEvent('groupBlurred')) + if (!isDeletingRef.current) { + // Commit any pending move FIRST so the (hidden) originals are synced + // to the overlay's final position before we reveal them below. + // Idempotent with the drag-end commit, so this won't double-record. + commitGroupMove() - const prevProps = { - metadata: c.metadata, - x: c.x, - y: c.y, - updatedBy: c.updatedBy, - } - updateComponentBulkPropertiesInLocalStore( - childId, - updateObj, - true - ) - blurBatchEntries.push({ - action: 'UPDATE_BULK', - id: childId, - prevProps, - bulkObj: updateObj, - }) - two.update() + const childrenIdsOfTheGroup = props.children.map( + (item: ShapeLike) => item.id + ) + let foundOriginalCount = 0 + two.scene.children.forEach((element: ShapeLike) => { + if (!element.elementData) return + if (childrenIdsOfTheGroup.includes(element.elementData.id)) { + foundOriginalCount++ + // Reveal the (now position-synced) original: restore its own + // group-level opacity — it was hidden at 0 under the overlay. + // metadata may be a pencil vertex array, so guard the read. + const elMeta = element.elementData.metadata + element.opacity = + elMeta && !Array.isArray(elMeta) + ? (elMeta.opacity ?? 1) + : 1 } }) - - if (blurBatchEntries.length > 0) { - recordBatchToHistoryLog(blurBatchEntries) - } + two.update() if (foundOriginalCount === 0 && props.children.length > 0) { const gx = parseInt(String(groupInstance.translation.x)) @@ -341,18 +411,31 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { group.translation.y = parseInt(String(prevY)) || 200 two.update() - for (let index = 0; index < props.children.length; index++) { - const item = props.children[index] + // Load every member's FACTORY chunk IN PARALLEL, then add them all + + // hide the on-canvas originals in a SINGLE two.update() so the + // group-select swap is atomic. Loading factories one-by-one and adding + // members as each resolved (a per-child two.update each), while newCanvas + // hid the originals up-front, left a blank frame (the flicker) between + // "originals hidden" and "members painted". Factories are prefetched + // (board.tsx warm list), so Promise.all resolves on the next microtask + // on a warm cache. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loaders = props.children.map((item: any) => { const factoryKey = `../../factory/${item.componentType}.ts` const loader = factoryModules[factoryKey] - if (typeof loader !== 'function') continue - loader().then((component) => { - const componentFactory = new component.default( - two, - item.x, - item.y, - { ...item } - ) + return typeof loader === 'function' + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + loader().then((mod: any) => ({ item, mod })) + : Promise.resolve(null) + }) + + Promise.all(loaders).then((resolved) => { + resolved.forEach((entry) => { + if (!entry) return + const { item, mod } = entry + const componentFactory = new mod.default(two, item.x, item.y, { + ...item, + }) const factoryObject = componentFactory.createElement() const coreObject = factoryObject.group coreObject.translation.x = item.x @@ -361,6 +444,19 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { coreObject.opacity = item.metadata.opacity } + // Standalone text: the factory makes ONE Two.Text from the raw + // content, but SVG collapses `\n` to a single line. Re-lay it out + // as the stacked multiline block (same as the newText component) + // so a grouped/duplicated text keeps its line breaks. + if (item.componentType === 'newText') { + layoutStandaloneText( + two, + coreObject, + item.metadata?.content ?? '', + item.metadata?.fontSize || 36 + ) + } + const meta = item.metadata || {} if (meta.hasText && meta.textContent) { const twoText = two.makeText(meta.textContent, 0, 0) @@ -369,16 +465,33 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { twoText.alignment = 'center' twoText.baseline = meta.textBaseLine || 'middle' twoText.family = - meta.textFontFamily || meta.textFamily || 'Caveat' + meta.textFontFamily || + meta.textFamily || + DEFAULT_TEXT_FONT_FAMILY coreObject.add(twoText) } coreObject.elementData = item - group.add(coreObject) - two.update() }) - } + orderGroupChildrenByZ(group) + + // Atomic swap: hide the on-canvas originals (group-SELECT only — + // `membersToHide` is unset for paste) in the SAME update that + // reveals the member copies, so there is never a blank frame. + const hideIds: string[] = Array.isArray(props.membersToHide) + ? props.membersToHide + : [] + if (hideIds.length) { + const hideSet = new Set(hideIds) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + if (hideSet.has(child?.elementData?.id)) child.opacity = 0 + }) + } + + two.update() + }) groupInstance = group @@ -408,6 +521,13 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { ) two.update() + // Baseline for commitGroupMove — the group's initial position. A move + // is only recorded once it diverges from this (then it advances). + lastCommitPosRef.current = { + x: parseInt(String(group.translation.x)), + y: parseInt(String(group.translation.y)), + } + setGroupId(group.id) return (): void => { @@ -418,6 +538,66 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Commit the move on drag-end so it lands in history immediately (like a + // single shape's mouseup), making it the entry an undo reverts even while + // the group stays selected. commitGroupMove is a no-op unless the group + // actually moved, so this safely fires on every mouseup. + useEffect(() => { + const onMouseUp = (): void => commitGroupMove() + window.addEventListener('mouseup', onMouseUp, false) + return (): void => + window.removeEventListener('mouseup', onMouseUp, false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Self-teardown when our members are removed out from under us — e.g. undo + // of a paste, which fires 'elementRemoved' per member via the history + // applyBatch. Without this the transient overlay (its member copies + the + // resize box) would linger over now-deleted shapes as a stale selection. + // We only dismiss the overlay here; the members are already being removed + // by whoever fired the event, so we never touch the store. The group's own + // Delete-key path sets isDeletingRef and owns its teardown, so we skip then. + useEffect(() => { + const memberIds = new Set( + (props.children ?? []) + .map((c: ShapeLike) => c?.id) + .filter(Boolean) + ) + const onMemberRemoved = ((e: CustomEvent<{ id: string }>): void => { + if (isDeletingRef.current) return + if (!memberIds.has(e.detail?.id)) return + // Already torn down (a sibling member fired first) — nothing to do. + if (!groupInstance || !isInScene(groupInstance)) return + + dismissOverlayNode() + // The overlay has done its job; drop the listener so dead overlays + // don't accumulate across repeated paste/undo cycles. + window.removeEventListener('elementRemoved', onMemberRemoved) + }) as EventListener + window.addEventListener('elementRemoved', onMemberRemoved) + return (): void => + window.removeEventListener('elementRemoved', onMemberRemoved) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // After an undo/redo, the overlay shows STATIC copies that can't reflect + // members the history just moved/re-added underneath it (applyBatch touches + // the real members, not the overlay's copies). Dismiss the overlay so the + // user sees the real, now-updated members: reveal their opacity, then drop + // the overlay. We don't commit here — the move was already reverted. + useEffect(() => { + const onHistoryApplied = (): void => { + if (isDeletingRef.current) return + if (!groupInstance || !isInScene(groupInstance)) return + revealMembers() + dismissOverlayNode() + } + window.addEventListener('historyApplied', onHistoryApplied) + return (): void => + window.removeEventListener('historyApplied', onHistoryApplied) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + useEffect(() => { const el = groupId ? document.getElementById(groupId) : null if (el) { diff --git a/src/components/elements/newText.tsx b/src/components/elements/newText.tsx index 3505396..8e95371 100644 --- a/src/components/elements/newText.tsx +++ b/src/components/elements/newText.tsx @@ -1,18 +1,13 @@ import React, { useEffect, useState, useRef } from 'react' import type { ReactElement } from 'react' -import interact from 'interactjs' import { useImmer } from 'use-immer' import { useBoardContext } from '../../views/Board/boardContext' -import { elementOnBlurHandler } from '../../utils/misc' -import getEditComponents from '../utils/editWrapper' import NewTextFactory from '../../factory/newText' -import { - TEXT_SIZES_OBJECT, - MOBILE_TEXT_SIZES_OBJECT, -} from '../../utils/constants' +import { syncTextHitRect } from '../../utils/canvasUtils' import { lineHeightFor } from '../../utils/textLayout' -import { useMediaQueryUtils } from '../../constants/exportHooks' +import { htmlToBulletText } from '../../utils/htmlToBulletText' +import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -21,13 +16,9 @@ type ShapeLike = any // eslint-disable-next-line @typescript-eslint/no-explicit-any type InternalState = Record -interface ResizeState { - centerX: number - centerY: number - startDist: number - startSize: number -} - +// Selection, drag-follow, font-resize and deletion are owned by the generic +// SelectionController (TEXT_ADAPTER with resizeMode:'font'). This component only +// renders the text and owns the inline text-edit overlay (dblclick → textarea). function NewText(props: ElementProps): ReactElement { const { updateComponentBulkPropertiesInLocalStore, @@ -37,11 +28,7 @@ function NewText(props: ElementProps): ReactElement { isArrowSelected, } = useBoardContext() - const { isMobile } = useMediaQueryUtils() - const [showToolbar, toggleToolbar] = useState(false) - const [, setShowMobilePanel] = useState(false) const [internalState, setInternalState] = useImmer({}) - const mobileTriggerRef = useRef(null) const [textValue, setTextValue] = useState( props?.metadata?.content || '' ) @@ -55,42 +42,9 @@ function NewText(props: ElementProps): ReactElement { const two = props.twoJSInstance - let selectorInstance: ShapeLike = null - let groupObject: ShapeLike = null - - function onBlurHandler(e: FocusEvent): void { - elementOnBlurHandler(e, selectorInstance, two) - if (groupObject) { - document - .getElementById(`${groupObject.id}`) - ?.removeEventListener('keydown', handleKeyDown) - } - } - - function handleKeyDown(e: KeyboardEvent): void { - if (e.keyCode === 8 || e.keyCode === 46) { - if (groupObject) { - document.getElementById(`${groupObject.id}`)?.blur() - props.handleDeleteComponent?.(groupObject) - two.remove([groupObject]) - } - two.update() - } - } - - function onFocusHandler(): void { - if (!groupObject) return - const el = document.getElementById(`${groupObject.id}`) - if (el) { - el.style.outline = '0' - el.addEventListener('keydown', handleKeyDown) - } - } - useEffect(() => { const prevX = props.x const prevY = props.y - let handleGlobalMousedown: ((e: MouseEvent) => void) | null = null const elementFactory = new NewTextFactory(two, prevX, prevY, props) const { group, twoText } = elementFactory.createElement() @@ -98,7 +52,6 @@ function NewText(props: ElementProps): ReactElement { twoText.opacity = props.metadata?.opacity ?? 1 twoTextRef.current = twoText - groupObject = group // Multiline rendering for standalone text: `twoText` holds line 1; // satellite Two.Text nodes hold lines 2..N. We honor only hard @@ -136,6 +89,9 @@ function NewText(props: ElementProps): ReactElement { const surplus = extra.splice(Math.max(n - 1, 0)) if (surplus.length > 0) group.remove(surplus) } + // Keep the transparent hit area covering the whole block so clicks + // in the gaps between lines still select the text (see canvasUtils). + syncTextHitRect(two, group) two.update() } syncMultilineRef.current = syncMultilineLayout @@ -180,120 +136,8 @@ function NewText(props: ElementProps): ReactElement { // Render any persisted multiline content as the stacked block. syncMultilineLayout() - - const { selector } = getEditComponents(two, group, 4) - selectorInstance = selector two.update() - // Resize via corner handles (proportional font-size scaling). - const cornerCircles: ShapeLike[] = [ - selectorInstance.circle1, - selectorInstance.circle2, - selectorInstance.circle3, - selectorInstance.circle4, - ].filter(Boolean) - - const resizeCursors = [ - 'nwse-resize', // circle1 = TL - 'nesw-resize', // circle2 = TR - 'nwse-resize', // circle3 = BR - 'nesw-resize', // circle4 = BL - ] - - let resizeState: ResizeState | null = null - - const onResizeMouseMove = (e: MouseEvent): void => { - if (!resizeState) return - const { centerX, centerY, startDist, startSize } = resizeState - const currentDist = Math.sqrt( - (e.clientX - centerX) ** 2 + (e.clientY - centerY) ** 2 - ) - const scale = currentDist / Math.max(startDist, 1) - const newSize = Math.round( - Math.min(Math.max(startSize * scale, 8), 300) - ) - - twoText.size = newSize - twoText.leading = newSize - extraLineNodesRef.current.forEach((nd) => { - nd.size = newSize - nd.leading = newSize - }) - // Re-stack for the new line height, then box the whole block. - syncMultilineLayout() - - const bRect = blockRect() - selectorInstance.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - - setTextSize(newSize) - } - - const onResizeMouseUp = (): void => { - if (!resizeState) return - const finalSize = twoText.size - resizeState = null - - window.removeEventListener('mousemove', onResizeMouseMove) - window.removeEventListener('mouseup', onResizeMouseUp) - - const bRect = blockRect() - const newWidth = Math.round(bRect.width || 60) - const newHeight = Math.round(bRect.height || twoText.size) - - const resizeMetadata = { - ...props.metadata, - fontSize: finalSize, - content: textValueRef.current, - } - updateComponentBulkPropertiesInLocalStore(props.id, { - width: newWidth, - height: newHeight, - metadata: resizeMetadata, - }) - if (group.elementData) { - group.elementData.metadata = resizeMetadata - } - } - - cornerCircles.forEach((circle, index) => { - const circleElem = circle._renderer?.elem as HTMLElement | undefined - if (!circleElem) return - - circleElem.style.cursor = resizeCursors[index] ?? 'pointer' - circleElem.style.pointerEvents = 'all' - - circleElem.addEventListener('mousedown', (e: MouseEvent) => { - if (selectorInstance.areaGroup.opacity === 0) return - - e.stopPropagation() - e.preventDefault() - - const textDomElem = twoText._renderer.elem - const textScreenRect = textDomElem.getBoundingClientRect() - const centerX = textScreenRect.left + textScreenRect.width / 2 - const centerY = textScreenRect.top + textScreenRect.height / 2 - - const startDist = Math.sqrt( - (e.clientX - centerX) ** 2 + (e.clientY - centerY) ** 2 - ) - - resizeState = { - centerX, - centerY, - startDist, - startSize: twoText.size || 16, - } - - window.addEventListener('mousemove', onResizeMouseMove) - window.addEventListener('mouseup', onResizeMouseUp) - }) - }) - const groupEl = document.getElementById(group.id) if (groupEl) { groupEl.setAttribute('class', 'dragger-picker') @@ -309,23 +153,94 @@ function NewText(props: ElementProps): ReactElement { }) const getGroupElementFromDOM = document.getElementById(`${group.id}`) - getGroupElementFromDOM?.addEventListener('focus', onFocusHandler) - getGroupElementFromDOM?.addEventListener('blur', onBlurHandler) const showTextInput = (): void => { + // A dblclick bubbles from the text node to the group, firing BOTH + // dblclick listeners below. Without this guard the second call reads + // getBoundingClientRect on the now-hidden group → (0,0) and drops a + // duplicate editor in the top-left corner. One editor at a time. + if (document.querySelector('.temp-input-area')) return + const groupDomElem = document.getElementById(`${group.id}`) if (!groupDomElem) return - const textDomElem = twoText._renderer.elem as HTMLElement - const screenRect = textDomElem.getBoundingClientRect() + // Live block screen rect = union of every line node's DOM rect. + // Re-read each frame so the editor follows the text as the canvas + // pans/zooms. The block is vertically centered on the group origin + // (line 1 sits at its TOP), so unioning ALL lines — not just line 1 — + // is what keeps multi-line text centered in the editor/box. + const blockScreenRect = (): { + left: number + top: number + width: number + height: number + } => { + const lineNodes = [ + twoTextRef.current, + ...extraLineNodesRef.current, + ].filter(Boolean) + let L = Infinity + let R = -Infinity + let T = Infinity + let Bm = -Infinity + lineNodes.forEach((nd: ShapeLike) => { + const el = nd?._renderer?.elem as HTMLElement | undefined + if (!el) return + const r = el.getBoundingClientRect() + L = Math.min(L, r.left) + R = Math.max(R, r.right) + T = Math.min(T, r.top) + Bm = Math.max(Bm, r.bottom) + }) + if (L === Infinity) { + const r = ( + twoText._renderer.elem as HTMLElement + ).getBoundingClientRect() + return { + left: r.left, + top: r.top, + width: r.width, + height: r.height, + } + } + return { left: L, top: T, width: R - L, height: Bm - T } + } + // Measure the text's real screen rect WHILE it's still visible, then + // hide it with display:none. (We must NOT use visibility:hidden: + // Two.js drives the SVG visibility/display from its own `.visible` + // flag and overwrites a CSS visibility we set on the next two.update, + // re-showing the text on top of the textarea — double text. opacity:0 + // is no good either: the renderer skips updating opacity-0 nodes, so + // typed changes wouldn't track.) With display:none we can't read the + // hidden text's rect, so we map its FIXED surface anchor → screen via + // the live camera instead. + const startRect = blockScreenRect() groupDomElem.style.display = 'none' - const fontSize = twoText.size || 36 - const sceneScale = two?.scene?.scale || 1 - const cssFontSize = fontSize * sceneScale - const lineH = Math.ceil(cssFontSize * 1.6) - const vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + const scale0 = two?.scene?.scale || 1 + // The anchor is invariant during edit: text is left-aligned at the + // group origin x and vertically centered on the group origin y. + const surfaceLeft = group.translation.x + const surfaceCenterY = group.translation.y + const surfaceWidth = startRect.width / scale0 + // Calibrate the constant part (canvas page offset + glyph bearing) + // from the real start position so there's no jump entering edit. + const calibX = + startRect.left - 8 - two.scene.translation.x - surfaceLeft * scale0 + const calibY = + startRect.top + + startRect.height / 2 - + two.scene.translation.y - + surfaceCenterY * scale0 + + // Camera-dependent geometry — recomputed each two.update (pan/zoom). + let cssFontSize = (twoText.size || 36) * scale0 + let lineH = Math.ceil(cssFontSize * 1.6) + let vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + let leftAnchor = 0 + let centerY = 0 + let minContentWidth = 0 const input = document.createElement('textarea') const randomId = Math.floor(Math.random() * 90 + 10) @@ -336,8 +251,14 @@ function NewText(props: ElementProps): ReactElement { input.style.background = 'transparent' input.style.padding = `${vertPad}px 8px` input.style.color = twoText.fill || '#3A342C' + // Match the element's current opacity so the editor doesn't flash to + // full opacity on entering edit mode. The opacity handler stores it + // on metadata (and applies it at group level), so read that. + input.style.opacity = String( + group.elementData?.metadata?.opacity ?? group.opacity ?? 1 + ) input.style.fontSize = `${cssFontSize}px` - input.style.fontFamily = twoText.family || 'Caveat' + input.style.fontFamily = twoText.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = twoText.weight || 'normal' input.style.lineHeight = `${lineH}px` input.style.letterSpacing = '0px' @@ -350,9 +271,6 @@ function NewText(props: ElementProps): ReactElement { input.style.boxSizing = 'border-box' input.className = 'temp-input-area' - const centerY = screenRect.top + screenRect.height / 2 - const leftAnchor = screenRect.left - 8 - document.getElementById('main-two-root')?.append(input) const measureSpan = document.createElement('span') @@ -360,13 +278,37 @@ function NewText(props: ElementProps): ReactElement { measureSpan.style.visibility = 'hidden' measureSpan.style.whiteSpace = 'pre' measureSpan.style.fontSize = `${cssFontSize}px` - measureSpan.style.fontFamily = twoText.family || 'Caveat' + measureSpan.style.fontFamily = + twoText.family || DEFAULT_TEXT_FONT_FAMILY measureSpan.style.fontWeight = twoText.weight || 'normal' measureSpan.style.lineHeight = `${lineH}px` measureSpan.style.letterSpacing = '0px' measureSpan.style.padding = '0' document.body.appendChild(measureSpan) + // Pull font + anchor from the LIVE camera + text position. Called on + // every two.update so the editor pans/zooms with the text. + const recomputeGeometry = (): void => { + const scale = two?.scene?.scale || 1 + cssFontSize = (twoText.size || 36) * scale + lineH = Math.ceil(cssFontSize * 1.6) + vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + // Map the fixed surface anchor → current screen via the live + // camera. No getBoundingClientRect (the text is display:none). + leftAnchor = + calibX + two.scene.translation.x + surfaceLeft * scale + centerY = + calibY + two.scene.translation.y + surfaceCenterY * scale + minContentWidth = surfaceWidth * scale + input.style.fontSize = `${cssFontSize}px` + input.style.lineHeight = `${lineH}px` + input.style.padding = `${vertPad}px 8px` + measureSpan.style.fontSize = `${cssFontSize}px` + measureSpan.style.lineHeight = `${lineH}px` + } + + // Pure DOM: size + place the textarea from the current geometry. + // No two.update here (so it's safe to call from the update handler). const autoSizeAndCenter = (): void => { const val = input.value || 'M' measureSpan.textContent = val @@ -376,7 +318,7 @@ function NewText(props: ElementProps): ReactElement { const contentWidth = Math.max( measuredW + 40, - screenRect.width + 40, + minContentWidth + 40, 80 ) const contentHeight = Math.max( @@ -386,27 +328,54 @@ function NewText(props: ElementProps): ReactElement { input.style.width = `${contentWidth}px` input.style.height = `${contentHeight}px` - input.style.left = `${leftAnchor}px` input.style.top = `${centerY - contentHeight / 2}px` } - autoSizeAndCenter() - - input.addEventListener('input', autoSizeAndCenter) + // Re-glue the editor to the text after any render (pan/zoom/content). + const repositionEditor = (): void => { + recomputeGeometry() + autoSizeAndCenter() + } - input.onfocus = function (): void { - const bRect = blockRect() - selectorInstance.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - selectorInstance.show() - two.update() + // On typing: push the value into the hidden Two.js text nodes so the + // SelectionController's box (and our live block rect) reflect it. The + // syncMultilineLayout's two.update fires 'update' → repositionEditor. + const onTextInput = (): void => { + textValueRef.current = input.value + syncMultilineLayout() + repositionEditor() } + repositionEditor() + two.bind('update', repositionEditor) + + input.addEventListener('input', onTextInput) + + // Pasting a bulleted list from a rich-text source (Docs, Notion, + // Notes) into this plain textarea would otherwise drop the bullet + // markers — the source's `text/plain` projection omits them. Read + // the `text/html` flavor and rebuild `• `-prefixed lines so list + // structure survives the paste. + input.addEventListener('paste', (event: ClipboardEvent) => { + const html = event.clipboardData?.getData('text/html') + if (!html) return + const converted = htmlToBulletText(html) + if (converted == null) return + + event.preventDefault() + const start = input.selectionStart ?? input.value.length + const end = input.selectionEnd ?? input.value.length + input.value = + input.value.slice(0, start) + + converted + + input.value.slice(end) + const caret = start + converted.length + input.selectionStart = caret + input.selectionEnd = caret + onTextInput() + }) + input.focus() input.addEventListener('keydown', (event: KeyboardEvent) => { @@ -427,7 +396,8 @@ function NewText(props: ElementProps): ReactElement { }) input.addEventListener('blur', () => { - input.removeEventListener('input', autoSizeAndCenter) + two.unbind('update', repositionEditor) + input.removeEventListener('input', onTextInput) if (measureSpan.parentNode) { measureSpan.parentNode.removeChild(measureSpan) } @@ -445,17 +415,19 @@ function NewText(props: ElementProps): ReactElement { const newWidth = Math.round(bRect.width || 60) const newHeight = Math.round(bRect.height || twoText.size) - selectorInstance.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - selectorInstance.hide() + // two.update() re-runs the SelectionController's 'update' bind, + // which re-syncs its box to the (possibly resized) text block. two.update() + // Merge onto the LIVE metadata (kept current by the toolbar's + // size/font/opacity handlers + controller resize), not the + // props snapshot frozen at mount — otherwise editing the text + // after a size/opacity change would write those stale values + // back and revert them on reload. + const baseMetadata = + group.elementData?.metadata ?? props.metadata ?? {} const updatedMetadata = { - ...props.metadata, + ...baseMetadata, content: textValueRef.current, } updateComponentBulkPropertiesInLocalStore(props.id, { @@ -487,50 +459,11 @@ function NewText(props: ElementProps): ReactElement { } window.addEventListener('triggerTextInput', handleTriggerTextInput) - interact(`#${group.id}`).on('click', () => { - const bRect = blockRect() - selector.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - two.update() - toggleToolbar(true) - }) - - handleGlobalMousedown = (e: MouseEvent): void => { - const path: EventTarget[] = e.composedPath - ? e.composedPath() - : [] - const isOnGroup = path.some( - (el: EventTarget) => (el as HTMLElement)?.id === group.id - ) - const isOnToolbar = path.some( - (el: EventTarget) => - (el as HTMLElement)?.id === 'floating-toolbar' - ) - const isOnMobileTrigger = - mobileTriggerRef.current && - path.includes(mobileTriggerRef.current) - if (!isOnGroup && !isOnToolbar && !isOnMobileTrigger) { - selectorInstance.hide() - toggleToolbar(false) - two.update() - } - } - window.addEventListener('mousedown', handleGlobalMousedown) - return (): void => { window.removeEventListener( 'triggerTextInput', handleTriggerTextInput ) - if (handleGlobalMousedown) { - window.removeEventListener('mousedown', handleGlobalMousedown) - } - window.removeEventListener('mousemove', onResizeMouseMove) - window.removeEventListener('mouseup', onResizeMouseUp) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -596,10 +529,6 @@ function NewText(props: ElementProps): ReactElement { ) }, [props.id, two]) - useEffect(() => { - if (!showToolbar) setShowMobilePanel(false) - }, [showToolbar]) - useEffect(() => { const groupId = internalState?.group?.id const el = groupId ? document.getElementById(groupId) : null @@ -620,11 +549,6 @@ function NewText(props: ElementProps): ReactElement { internalState?.group?.id, ]) - // TEXT_SIZES_OBJECT and MOBILE_TEXT_SIZES_OBJECT used by callbacks - // wired in via the toolbar; keep imports referenced via a no-op. - void TEXT_SIZES_OBJECT - void MOBILE_TEXT_SIZES_OBJECT - return (
diff --git a/src/components/elements/rectangle.tsx b/src/components/elements/rectangle.tsx index 5fa5a88..d147005 100644 --- a/src/components/elements/rectangle.tsx +++ b/src/components/elements/rectangle.tsx @@ -29,10 +29,11 @@ function Rectangle(props: ElementProps): ReactElement { }) const { group, rectangle } = elementFactory.createElement() group.elementData = { ...props.itemData, ...props } - rectangle.opacity = props.metadata?.opacity ?? 1 + const opacityValue = props.metadata?.opacity ?? 1 if (props.parentGroup) { const parentGroup = props.parentGroup + rectangle.opacity = opacityValue parentGroup.add(rectangle) two.update() } else { @@ -50,6 +51,12 @@ function Rectangle(props: ElementProps): ReactElement { meta ) + // Apply opacity at the group level so the shape and any embedded + // text dim uniformly, and so it actually repaints (the rounded-rect + // path is double-referenced in group.children via the unshift above, + // which leaves leaf-level opacity flags unprocessed on render). + group.opacity = opacityValue + two.update() const groupEl = document.getElementById(group.id) diff --git a/src/components/sidebar/elementProperties copy.tsx b/src/components/sidebar/elementProperties copy.tsx new file mode 100644 index 0000000..7293007 --- /dev/null +++ b/src/components/sidebar/elementProperties copy.tsx @@ -0,0 +1,727 @@ +import React, { Fragment, useEffect, useMemo, useState } from 'react' + +import { useBoardContext } from '../../views/Board/boardContext' +import { useMediaQueryUtils } from '../../constants/exportHooks' +import ColorPicker from '../utils/colorPicker' +import OpacitySlider from '../utils/opacitySlider' +import Tooltip from '../common/tooltip' +import { TEXT_SIZES_ARRAY, fillEssentialShades } from '../../utils/constants' +import { MIXED, inspectGroupValues } from '../../utils/groupInspect' +import { isStandaloneTextType } from '../../constants/misc' +import type { ReorderOp } from '../canvasContextMenu' +import BringToFrontIcon from '../../assets/bring-to-front.svg?react' +import BringForwardIcon from '../../assets/bring-forward.svg?react' +import SendBackwardIcon from '../../assets/send-backward.svg?react' +import SendToBackIcon from '../../assets/send-to-back.svg?react' + +// Reorder icon tone — matches the toolbar's ink-mid text. The source SVGs +// hardcode a blue stroke; SVGR spreads props after the original attrs so this +// override wins (same trick as canvasContextMenu). +const REORDER_ICON_STROKE = '#8C7E6A' + +const REORDER_BUTTONS: { + op: ReorderOp + label: string + Icon: React.FunctionComponent> +}[] = [ + { op: 'front', label: 'Bring to Front', Icon: BringToFrontIcon }, + { op: 'forward', label: 'Bring Forward', Icon: BringForwardIcon }, + { op: 'backward', label: 'Send Backward', Icon: SendBackwardIcon }, + { op: 'back', label: 'Send to Back', Icon: SendToBackIcon }, +] + +const STROKE_TYPES = [ + { label: '—', value: 'solid' }, + { label: '- -', value: 'dashed' }, + { label: '...', value: 'dotted' }, +] + +const STROKE_WIDTHS = [ + { label: '0', value: 0, strokeHeight: '0px' }, + { label: '2', value: 2, strokeHeight: '2px' }, + { label: '4', value: 4, strokeHeight: '4px' }, + { label: '6', value: 6, strokeHeight: '6px' }, +] + +// What sections each "set" should render, in display order. +const SETS = { + SHAPE: ['fill', 'stroke', 'strokeWidth', 'strokeType', 'opacity'], + ARROW: ['stroke', 'strokeWidth', 'strokeType', 'opacity'], + PENCIL: ['stroke', 'strokeWidth', 'strokeType'], + TEXT: ['textColor', 'textSize', 'textFont', 'opacity'], + // Geo objects: stroke-centric. Area's fill is auto-derived from stroke, so + // no fill control — but its outline still takes width/type like a route. + // Point has no edit-area set: its category is chosen from the point drawer + // in the shapes toolbar (resolveSetKey returns null for points). + GEO_AREA: ['stroke', 'strokeWidth', 'strokeType'], + GEO_ROUTE: ['stroke', 'strokeWidth', 'strokeType'], + RECT_WITH_TEXT: [ + 'fill', + 'stroke', + 'strokeWidth', + 'strokeType', + 'opacity', + 'textColor', + 'textSize', + 'textFont', + ], + // GROUP: union of every property — toolbar shows them all when a group + // is focused. applyGroupProperty silently skips children whose element + // type doesn't accept a given property. + GROUP: [ + 'fill', + 'stroke', + 'strokeWidth', + 'strokeType', + 'opacity', + 'textColor', + 'textSize', + 'textFont', + ], +} + +const SET_LABELS = { + SHAPE: 'Shape', + ARROW: 'Arrow', + PENCIL: 'Pencil', + TEXT: 'Text', + RECT_WITH_TEXT: 'Shape', + GROUP: 'Group', + GEO_AREA: 'Area', + GEO_ROUTE: 'Route', +} + +interface ResolveSetKeyOptions { + isRubberMode: boolean + isPencilMode: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedComponent: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedGroup: any + isTextDrawMode: boolean + // Active tool from the toolbar (e.g. 'route'/'area'/'point' while a geo + // draw is in progress, before any vertex/element is selected). + currentElement: string | null +} + +function resolveSetKey({ + isRubberMode, + isPencilMode, + selectedComponent, + selectedGroup, + isTextDrawMode, + currentElement, +}: ResolveSetKeyOptions): string | null { + // A focused group beats every other mode — show the union toolbar. + if (selectedGroup) return 'GROUP' + if (isRubberMode) return null + if (isPencilMode) return 'PENCIL' + if (selectedComponent) { + const shapeType = selectedComponent?.shape?.type + const hasText = typeof selectedComponent?.text?.data?.value === 'string' + const elementType = + selectedComponent?.group?.data?.elementData?.componentType + // Geo objects checked first: area/route are path-typed and would + // otherwise fall into the SHAPE branch below. Points have no edit-area + // panel — their category is set from the toolbar drawer. + if (elementType === 'point') return null + if (elementType === 'area') return 'GEO_AREA' + if (elementType === 'route') return 'GEO_ROUTE' + // rectangle/diamond/circle all carry text the same way — show the + // shape+text toolbar (text size/color/font) for any of them. + const isShapeWithText = + hasText && + (shapeType === 'rectangle' || + elementType === 'rectangle' || + elementType === 'diamond' || + elementType === 'circle') + if (isShapeWithText) return 'RECT_WITH_TEXT' + if ( + shapeType === 'rectangle' || + shapeType === 'circle' || + shapeType === 'ellipse' || + shapeType === 'diamond' || + shapeType === 'path' || + shapeType === 'rounded-rectangle' + ) + return 'SHAPE' + if (shapeType === 'arrowLine') return 'ARROW' + if (isStandaloneTextType(shapeType)) return 'TEXT' + // Diamond is a custom Path; the elementData carries the type. + if ( + elementType === 'diamond' || + elementType === 'rectangle' || + elementType === 'circle' + ) + return 'SHAPE' + if (elementType === 'arrowLine') return 'ARROW' + if (isStandaloneTextType(elementType)) return 'TEXT' + if (elementType === 'pencil') return 'PENCIL' + return 'SHAPE' + } + // Arrow intentionally has no armed-mode panel: the ARROW toolbar appears + // only once a drawn arrow is selected (handled above), so merely picking the + // arrow tool doesn't surface the edit toolbar. + if (isTextDrawMode) return 'TEXT' + // Geo draw modes: the toolbar tool is active but nothing is selected yet. + // Surface the geo property panel so stroke edits seed the next draw — the + // same way pencil mode shows the pencil panel before the first stroke. + // Point is excluded: its category lives in the toolbar drawer, not here. + if (currentElement === 'area') return 'GEO_AREA' + if (currentElement === 'route') return 'GEO_ROUTE' + // No selection and no active tool — hide the panel. Defaults still apply + // to the next-created shape (the `useElementDefaults` state is unchanged); + // users edit them by selecting a shape, which auto-syncs the default per + // createApplyProperty. + return null +} + +interface ReadEffectiveValuesOptions { + setKey: string | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedComponent: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedGroup: any + isMobile: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + defaults: any +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function readEffectiveValues({ + setKey, + selectedComponent, + selectedGroup, + isMobile, + defaults, +}: ReadEffectiveValuesOptions): any { + // Group mode: walk the group's children and report common values, falling + // back to defaults when no child carries the property and to MIXED when + // children disagree. + if (setKey === 'GROUP' && selectedGroup) { + const inspected = inspectGroupValues(selectedGroup, defaults) + // textSize is stored numeric in metadata; map to the toolbar label. + let textSizeOut = inspected.textSize + if (textSizeOut !== MIXED) { + const match = TEXT_SIZES_ARRAY.find((s) => + isMobile + ? s.mobileValue === textSizeOut + : s.value === textSizeOut + ) + textSizeOut = match?.label ?? defaults.defaultTextSize + } + return { ...inspected, textSize: textSizeOut } + } + if (!selectedComponent) { + // Pure default mode — pencil/arrow/shape all share the same defaults. + return { + fill: defaults.defaultFill, + stroke: defaults.defaultStrokeColor, + linewidth: defaults.defaultLinewidth, + strokeType: defaults.defaultStrokeType ?? 'solid', + opacity: defaults.defaultOpacity ?? 1, + textColor: defaults.defaultTextColor, + textSize: defaults.defaultTextSize, + textFontFamily: defaults.defaultTextFontFamily, + } + } + + const shapeData = selectedComponent?.shape?.data + const elementData = selectedComponent?.group?.data?.elementData + const textData = selectedComponent?.text?.data + + // For rectangle-with-text + plain text, the text properties live in + // different places. Resolve here so the rest is symmetric. + const isText = isStandaloneTextType(selectedComponent?.shape?.type) + const textNode = isText ? shapeData : textData + + const textSizeNumeric = textNode?.size + const textSizeLabel = + TEXT_SIZES_ARRAY.find((s) => + isMobile + ? s.mobileValue === textSizeNumeric + : s.value === textSizeNumeric + )?.label || defaults.defaultTextSize + + return { + fill: shapeData?.fill ?? defaults.defaultFill, + stroke: shapeData?.stroke ?? defaults.defaultStrokeColor, + linewidth: shapeData?.linewidth ?? defaults.defaultLinewidth, + strokeType: elementData?.strokeType ?? 'solid', + opacity: elementData?.metadata?.opacity ?? 1, + textColor: isText + ? (shapeData?.fill ?? defaults.defaultTextColor) + : (textData?.fill ?? defaults.defaultTextColor), + textSize: textSizeLabel, + textFontFamily: textNode?.family ?? defaults.defaultTextFontFamily, + } +} + +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+) + +const ReorderRow = ({ onReorder }: { onReorder: (op: ReorderOp) => void }) => ( +
+ Reorder +
+ {REORDER_BUTTONS.map(({ op, label, Icon }) => ( + + + + ))} +
+
+) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const StrokeWidthRow = ({ + value, + onChange, +}: { + value: any + onChange: (v: number) => void +}) => ( +
+ Stroke Width +
+ {STROKE_WIDTHS.map(({ value: w, strokeHeight }) => { + const isSelected = value === w + return ( + + ) + })} +
+
+) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const StrokeTypeRow = ({ + value, + onChange, +}: { + value: any + onChange: (v: string) => void +}) => ( +
+ Stroke Type +
+ {STROKE_TYPES.map(({ label, value: v }) => { + const isSelected = (value ?? 'solid') === v + return ( + + ) + })} +
+
+) + +const TextSizeRow = ({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) => ( +
+ Text Size +
+ {TEXT_SIZES_ARRAY.map(({ label }) => { + const isSelected = value === label + return ( + + ) + })} +
+
+) + +const FontFamilyRow = ({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) => { + const families = [ + { label: 'Caveat', family: 'Caveat' }, + { label: 'Geist', family: 'Geist' }, + { label: 'Caveat Brush', family: 'Caveat Brush' }, + ] + return ( +
+ Font +
+ {families.map(({ family }) => { + const isSelected = value === family + return ( + + ) + })} +
+
+ ) +} + +const ElementPropertiesToolbar = () => { + const ctx = useBoardContext() + + const { + isPencilMode, + isTextDrawMode, + isRubberMode, + selectedComponent, + selectedGroup, + currentElement, + applyProperty, + applyGroupProperty, + reorderSelected, + showMobileToolbarPanel, + // defaults + defaultFill, + defaultStrokeColor, + defaultLinewidth, + defaultStrokeType, + defaultOpacity, + defaultTextColor, + defaultTextSize, + defaultTextFontFamily, + } = ctx + const { isMobile } = useMediaQueryUtils() + + const setKey = useMemo( + () => + resolveSetKey({ + isRubberMode, + isPencilMode, + selectedComponent, + selectedGroup, + isTextDrawMode, + currentElement, + }), + [ + isRubberMode, + isPencilMode, + selectedComponent, + selectedGroup, + isTextDrawMode, + currentElement, + ] + ) + + const defaults = { + defaultFill, + defaultStrokeColor, + defaultLinewidth, + defaultStrokeType, + defaultOpacity, + defaultTextColor, + defaultTextSize, + defaultTextFontFamily, + } + + const [values, setValues] = useState(() => + readEffectiveValues({ + setKey: setKey || 'SHAPE', + selectedComponent, + selectedGroup, + isMobile, + defaults, + }) + ) + + const [expandedSection, setExpandedSection] = useState(null) + + const toggleSection = (key: string): void => + setExpandedSection((prev) => (prev === key ? null : key)) + + // Collapse any open color picker when context changes (new selection, mode switch). + useEffect(() => { + setExpandedSection(null) + }, [setKey, selectedComponent, selectedGroup]) + + // Re-sync local UI state whenever the source of truth changes (selection, + // mode, or any default). Property mutations bump a default, which flows + // through this effect to refresh the readouts. + useEffect(() => { + if (!setKey) return + setValues( + readEffectiveValues({ + setKey, + selectedComponent, + selectedGroup, + isMobile, + defaults, + }) + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + setKey, + selectedComponent, + selectedGroup, + isMobile, + defaultFill, + defaultStrokeColor, + defaultLinewidth, + defaultStrokeType, + defaultOpacity, + defaultTextColor, + defaultTextSize, + defaultTextFontFamily, + ]) + + if (!setKey) return null + if (isMobile && !showMobileToolbarPanel) return null + + const sections = SETS[setKey as keyof typeof SETS] + // Reorder controls apply to an actually-selected element (single shape or + // group). Hidden in pencil mode (armed pencil has no selection to reorder) + // and in the default/armed-tool panels where nothing is selected yet. + const showReorder = + Boolean(selectedComponent || selectedGroup) && !isPencilMode + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handle = + (key: string, opts?: { preview?: boolean }) => + (val: any): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setValues((prev: any) => ({ ...prev, [key]: val })) + if (selectedGroup) { + // textSize comes through as a label (e.g. 'M'); resolve to the + // numeric Two.js size before forwarding to the bulk apply path. + // The single-element path goes through handleTextSizeChange which + // does this conversion internally; the group path doesn't, so we + // do it here. + if (key === 'textSize') { + const entry = TEXT_SIZES_ARRAY.find((s) => s.label === val) + const numeric = entry + ? isMobile + ? entry.mobileValue + : entry.value + : val + applyGroupProperty?.(key, numeric, opts) + } else { + applyGroupProperty?.(key, val, opts) + } + return + } + applyProperty?.(key, val, opts) + } + + return ( +
{ + if (!selectedGroup) return + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + e.preventDefault() + }} + className="secondary-sidebar-content fixed bg-card-bg block text-left pb-4 rounded-card shadow-card border border-border-panel overflow-y-auto tablet:max-h-128" + style={ + isMobile + ? { + bottom: '60px', + right: '10px', + width: '208px', + zIndex: 20, + } + : { left: '10px', top: '56px', width: '13rem' } + } + > +
+ {SET_LABELS[setKey as keyof typeof SET_LABELS]} +
+ + {sections.includes('fill') && ( +
+ toggleSection('fill')} + essentialColors={fillEssentialShades} + /> +
+ )} + + {sections.includes('stroke') && ( +
+ toggleSection('stroke')} + /> +
+ )} + + {sections.includes('strokeWidth') && ( + + )} + + {sections.includes('strokeType') && ( + + )} + + {sections.includes('textColor') && ( +
+ toggleSection('textColor')} + /> +
+ )} + + {sections.includes('textSize') && ( + + )} + + {sections.includes('textFont') && ( + + )} + + {sections.includes('opacity') && ( +
+ Opacity + + // Live preview while dragging: applies to the scene + // only (no store/history write) so the element fades + // in real time. The release (handleOnChange) commits. + handle('opacity', { preview: true })(arr[0]) + } + handleOnChange={(arr) => handle('opacity')(arr[0])} + /> +
+ )} + + {showReorder && } +
+ ) +} + +export default ElementPropertiesToolbar diff --git a/src/components/sidebar/elementProperties.tsx b/src/components/sidebar/elementProperties.tsx index 4497456..1757b6b 100644 --- a/src/components/sidebar/elementProperties.tsx +++ b/src/components/sidebar/elementProperties.tsx @@ -4,9 +4,31 @@ import { useBoardContext } from '../../views/Board/boardContext' import { useMediaQueryUtils } from '../../constants/exportHooks' import ColorPicker from '../utils/colorPicker' import OpacitySlider from '../utils/opacitySlider' +import Tooltip from '../common/tooltip' import { TEXT_SIZES_ARRAY, fillEssentialShades } from '../../utils/constants' import { MIXED, inspectGroupValues } from '../../utils/groupInspect' import { isStandaloneTextType } from '../../constants/misc' +import type { ReorderOp } from '../canvasContextMenu' +import BringToFrontIcon from '../../assets/bring-to-front.svg?react' +import BringForwardIcon from '../../assets/bring-forward.svg?react' +import SendBackwardIcon from '../../assets/send-backward.svg?react' +import SendToBackIcon from '../../assets/send-to-back.svg?react' + +// Reorder icon tone — matches the toolbar's ink-mid text. The source SVGs +// hardcode a blue stroke; SVGR spreads props after the original attrs so this +// override wins (same trick as canvasContextMenu). +const REORDER_ICON_STROKE = '#8C7E6A' + +const REORDER_BUTTONS: { + op: ReorderOp + label: string + Icon: React.FunctionComponent> +}[] = [ + { op: 'front', label: 'Bring to Front', Icon: BringToFrontIcon }, + { op: 'forward', label: 'Bring Forward', Icon: BringForwardIcon }, + { op: 'backward', label: 'Send Backward', Icon: SendBackwardIcon }, + { op: 'back', label: 'Send to Back', Icon: SendToBackIcon }, +] const STROKE_TYPES = [ { label: '—', value: 'solid' }, @@ -14,11 +36,16 @@ const STROKE_TYPES = [ { label: '...', value: 'dotted' }, ] +// `inner` is the diameter (px) of the inner circle that visually nudges +// the actual stroke width inside a constant outer ring. `0` renders the +// "no stroke" state (a diagonal slash) instead of an inner circle. const STROKE_WIDTHS = [ - { label: '0', value: 0, strokeHeight: '0px' }, - { label: '2', value: 2, strokeHeight: '2px' }, - { label: '4', value: 4, strokeHeight: '4px' }, - { label: '6', value: 6, strokeHeight: '6px' }, + { label: '0', value: 0, inner: 0 }, + { label: '1', value: 1, inner: 4 }, + { label: '2', value: 2, inner: 6 }, + { label: '4', value: 4, inner: 9 }, + { label: '6', value: 6, inner: 12 }, + { label: '8', value: 8, inner: 15 }, ] // What sections each "set" should render, in display order. @@ -241,6 +268,34 @@ const SectionLabel = ({ children }: { children: React.ReactNode }) => (
) +const ReorderRow = ({ onReorder }: { onReorder: (op: ReorderOp) => void }) => ( +
+ Reorder +
+ {REORDER_BUTTONS.map(({ op, label, Icon }) => ( + + + + ))} +
+
+) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const StrokeWidthRow = ({ value, @@ -251,42 +306,52 @@ const StrokeWidthRow = ({ }) => (
Stroke Width -
- {STROKE_WIDTHS.map(({ value: w, strokeHeight }) => { +
+ {STROKE_WIDTHS.map(({ value: w, inner }) => { const isSelected = value === w + const accent = isSelected ? '#C4901A' : '#8C7E6A' return ( ) })} @@ -377,6 +442,7 @@ const FontFamilyRow = ({ const families = [ { label: 'Caveat', family: 'Caveat' }, { label: 'Geist', family: 'Geist' }, + { label: 'Caveat Brush', family: 'Caveat Brush' }, ] return (
@@ -416,6 +482,7 @@ const ElementPropertiesToolbar = () => { currentElement, applyProperty, applyGroupProperty, + reorderSelected, showMobileToolbarPanel, // defaults defaultFill, @@ -514,9 +581,14 @@ const ElementPropertiesToolbar = () => { if (isMobile && !showMobileToolbarPanel) return null const sections = SETS[setKey as keyof typeof SETS] + // Reorder controls apply to an actually-selected element (single shape or + // group). Hidden in pencil mode (armed pencil has no selection to reorder) + // and in the default/armed-tool panels where nothing is selected yet. + const showReorder = + Boolean(selectedComponent || selectedGroup) && !isPencilMode // eslint-disable-next-line @typescript-eslint/no-explicit-any const handle = - (key: string) => + (key: string, opts?: { preview?: boolean }) => (val: any): void => { // eslint-disable-next-line @typescript-eslint/no-explicit-any setValues((prev: any) => ({ ...prev, [key]: val })) @@ -533,13 +605,13 @@ const ElementPropertiesToolbar = () => { ? entry.mobileValue : entry.value : val - applyGroupProperty?.(key, numeric) + applyGroupProperty?.(key, numeric, opts) } else { - applyGroupProperty?.(key, val) + applyGroupProperty?.(key, val, opts) } return } - applyProperty?.(key, val) + applyProperty?.(key, val, opts) } return ( @@ -652,16 +724,17 @@ const ElementPropertiesToolbar = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setValues((prev: any) => ({ - ...prev, - opacity: arr[0], - })) + // Live preview while dragging: applies to the scene + // only (no store/history write) so the element fades + // in real time. The release (handleOnChange) commits. + handle('opacity', { preview: true })(arr[0]) } handleOnChange={(arr) => handle('opacity')(arr[0])} />
)} + + {showReorder && }
) } diff --git a/src/components/sidebar/menuDrawer.tsx b/src/components/sidebar/menuDrawer.tsx index a7bc092..218d2ef 100644 --- a/src/components/sidebar/menuDrawer.tsx +++ b/src/components/sidebar/menuDrawer.tsx @@ -6,6 +6,8 @@ import { useBoardContext } from '../../views/Board/boardContext' import { downloadViewportAsImage } from '../../utils/exportViewport' import Modal from '../common/modal' import Button from '../common/button' +import SettingsModal from './settingsModal' +import settingsIcon from '../../assets/settings.svg' const HamburgerIcon = (): ReactElement => ( { const refNode = useRef(null) const [showMenu, setShowMenu] = useState(false) const [showConfirm, setShowConfirm] = useState(false) + const [showSettings, setShowSettings] = useState(false) const [isExporting, setIsExporting] = useState(false) const { clearBoard } = useBoardContext() @@ -112,6 +115,11 @@ const MenuDrawer = (): ReactElement => { setShowConfirm(true) } + const handleSettingsClick = (): void => { + setShowMenu(false) + setShowSettings(true) + } + const handleDownloadClick = async (): Promise => { setShowMenu(false) try { @@ -154,7 +162,7 @@ const MenuDrawer = (): ReactElement => {
{ pointerEvents: showMenu ? 'auto' : 'none', }} > -
-
- - More - -
- -
- +
{ + setShowMenu(false)} + > + + + + Embeddable whiteboard + + + + +
+
+ + ) +} + +export default SettingsModal diff --git a/src/components/sidebar/shapesToolbar.tsx b/src/components/sidebar/shapesToolbar.tsx index 93c2bf6..7fc6294 100644 --- a/src/components/sidebar/shapesToolbar.tsx +++ b/src/components/sidebar/shapesToolbar.tsx @@ -102,17 +102,6 @@ const ShapesToolbar = ({ addElement }: ShapesToolbarProps): ReactElement => { const list = ( isMobile ? allElementsRaw : flattenShapesForDesktop(allElementsRaw) ) - .filter((el) => { - // Pan is normally mobile-only; surface it on desktop too when - // geo objects are enabled so the default tool is reachable. - if (el.mobileOnly) { - return ( - isMobile || - (geoObjectsEnabled && el.elementName === 'pan') - ) - } - return true - }) // Whiteboard shape tools are hidden in geo mode in favour of the // geo toolset (point/area/route/geoText). .filter( diff --git a/src/components/utils/colorPicker.tsx b/src/components/utils/colorPicker.tsx index 06fa0f6..472d4ac 100644 --- a/src/components/utils/colorPicker.tsx +++ b/src/components/utils/colorPicker.tsx @@ -135,7 +135,7 @@ const ColorPicker = ({
{title && ( - + {title} )} diff --git a/src/constants/misc.ts b/src/constants/misc.ts index 27f0546..39a0a7a 100644 --- a/src/constants/misc.ts +++ b/src/constants/misc.ts @@ -1,5 +1,13 @@ export const offsetHeight = 0 export const GROUP_COMPONENT = 'groupobject' + +// Default canvas text font (single source of truth). Kept in sync with the +// `--font-sketch` / `--font-caveat-brush` vars in App.css, the Tailwind +// `sketch` token, and the Google Fonts in index.html. Every canvas-text +// fallback (`family || DEFAULT_TEXT_FONT_FAMILY`) references this so the default +// lives in exactly one place. Only the Regular 400 weight is loaded/used. +export const DEFAULT_TEXT_FONT_FAMILY = 'Caveat Brush' + export const RUBBER_MODE_KEY = 'rubberMode' export const VIEWPORT_KEY_PREFIX = 'craftbase_viewport_' export const MOBILE_VIEWPORT_KEY_PREFIX = 'craftbase_mobile_viewport_' @@ -191,6 +199,11 @@ export const STORAGE_QUOTA_ERROR_NAME = 'QuotaExceededError' // never seed again for this browser profile. export const WELCOME_DISMISSED_KEY = 'craftbase_welcome_dismissed' +// Feature-flag preference: connectable arrows / shape edge ports. User-toggled +// in the Settings modal, persisted in localStorage, read live (see +// `src/utils/featureFlags.ts`). Defaults to enabled. +export const CONNECTORS_ENABLED_KEY = 'craftbase_connectors_enabled' + // Canvas rendering constants export const HOVER_THRESHOLD = 15 export const HOVER_COLOR = 'rgba(196, 144, 26, 0.7)' diff --git a/src/elementModules.ts b/src/elementModules.ts new file mode 100644 index 0000000..922a41e --- /dev/null +++ b/src/elementModules.ts @@ -0,0 +1,29 @@ +// Single source of truth for the lazily-loaded whiteboard element components. +// +// Each file under components/elements/*.tsx is its own dynamic chunk — Vite +// code-splits non-eager `import.meta.glob` — and is mounted via React.lazy in +// newCanvas.tsx. On a fresh page the FIRST draw of a given shape type pays a +// network fetch + parse of that chunk before React can mount it; that is the +// "freshly drawn shape sits dimmed for a couple seconds" cost on prod. +// +// Lives at the src root so the glob path (and therefore the produced keys, +// e.g. './components/elements/circle.tsx') matches newCanvas's original glob +// verbatim — newCanvas keys into this map with that exact string. + +export const elementModules = import.meta.glob('./components/elements/*.tsx') + +// Idempotent prefetch: kicks off (and caches) the dynamic import for a shape +// type so its chunk is warm before React.lazy needs it. Calling it repeatedly +// reuses the in-flight/resolved promise, and the browser dedupes the import, +// so the real mount path (React.lazy) resolves instantly once warmed. +const inFlight = new Map>() + +export function prefetchElementModule(componentType: string): void { + const key = `./components/elements/${componentType}.tsx` + const loader = elementModules[key] + if (!loader) return + if (inFlight.has(key)) return + // Best-effort warm-up — the real load path surfaces genuine failures via + // its own Suspense/error boundary, so swallow here. + inFlight.set(key, loader().catch(() => undefined)) +} diff --git a/src/factory/newText.ts b/src/factory/newText.ts index 4e23566..58fba0a 100644 --- a/src/factory/newText.ts +++ b/src/factory/newText.ts @@ -1,4 +1,5 @@ import Main from './main' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' export interface NewTextMetadata { content?: string @@ -23,7 +24,7 @@ export default class NewTextFactory extends Main { const { content = '', fontSize = 36, - textFontFamily = 'Caveat', + textFontFamily = DEFAULT_TEXT_FONT_FAMILY, } = this.properties?.metadata ?? {} // Use native Two.js text instead of a foreignObject wrapper diff --git a/src/hooks/useCanvasClipboard.ts b/src/hooks/useCanvasClipboard.ts index f2a82d4..7a19748 100644 --- a/src/hooks/useCanvasClipboard.ts +++ b/src/hooks/useCanvasClipboard.ts @@ -2,8 +2,13 @@ import { useEffect, useRef } from 'react' import type { MutableRefObject } from 'react' import { GROUP_COMPONENT, isStandaloneTextType } from '../constants/misc' import { generateUUID } from '../utils/misc' -import { cloneElementData, getShapeTextNodes } from '../utils/canvasUtils' +import { + cloneElementData, + getShapeTextNodes, + pollUntilElement, +} from '../utils/canvasUtils' import type { ComponentRecord } from '../types/board' +import type { HistoryEntry } from './useComponentHistory' // Two.js scene objects are typed loosely here; canvas-side typing converges // in Stages 7–9. @@ -41,8 +46,10 @@ export interface CanvasClipboardOptions { addToLocalComponentStore: ( id: string, componentType: string, - record: ComponentRecord + record: ComponentRecord, + skipHistory?: boolean ) => void + recordBatchToHistoryLog: (entries: HistoryEntry[]) => void renderGroupRef: MutableRefObject< ((groups: ComponentRecord[]) => void) | null > @@ -58,6 +65,7 @@ export function useCanvasClipboard({ zuiInstanceRef, boardId, addToLocalComponentStore, + recordBatchToHistoryLog, renderGroupRef, }: CanvasClipboardOptions): CanvasClipboardApi { const clipboardRef = useRef(null) @@ -256,6 +264,97 @@ export function useCanvasClipboard({ cloned.relativeY = rY return cloned }) + + // Persist the pasted members to the store IMMEDIATELY, at + // absolute coords (paste origin + each child's relative offset). + // Previously the children were only written on the group's + // blur-materialize (groupobject's foundOriginalCount===0 path), + // which meant a reload while the pasted group was still selected + // lost them — they lived only as transient overlay copies, never + // in componentStore / the localStorage draft. Persisting here + // makes paste reload-safe and lets the overlay below be a pure + // selection over real standalones (see membersToHide), so blur + // takes the restore-opacity path instead of re-materialising — + // killing the teardown→async-rebuild flicker too. + const memberIds: string[] = [] + // Record all member adds as ONE batch so a single undo removes + // the whole pasted group (not one shape per press). We pass + // skipHistory to addToLocalComponentStore and push an ADD entry + // per child, then commit them together via recordBatchToHistoryLog. + const pasteBatchEntries: HistoryEntry[] = [] + newChildren.forEach((child: ComponentRecord) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = child as any + const absX = px + (c.relativeX ?? 0) + const absY = py + (c.relativeY ?? 0) + + // pencil / geo area / route keep their geometry as an + // absolute {x,y} vertex array in metadata — not in x/y. The + // group child stored it in the group's relative space, so + // rebase the whole array to the standalone's absolute origin + // (mirrors groupobject's blur-materialize). Without this the + // pasted stroke renders near the origin instead of under the + // group — i.e. "the pencil strokes disappear". + let memberMetadata = c.metadata + if ( + (c.componentType === 'pencil' || + c.componentType === 'area' || + c.componentType === 'route') && + Array.isArray(c.metadata) + ) { + const meta = c.metadata as Array<{ + x: number + y: number + lw?: number + }> + const m0 = meta[0] ?? { x: 0, y: 0 } + memberMetadata = meta.map((vert, index) => { + const lwProp = + vert.lw !== undefined ? { lw: vert.lw } : {} + if (index === 0) { + return { x: absX, y: absY, ...lwProp } + } + return { + x: absX + Math.trunc(vert.x - m0.x), + y: absY + Math.trunc(vert.y - m0.y), + ...lwProp, + } + }) + } + + const memberData = { + ...c, + x: absX, + y: absY, + metadata: memberMetadata, + } + memberIds.push(c.id) + addToLocalComponentStore( + c.id, + c.componentType, + memberData, + true + ) + // The history entry's componentInfo must mirror the stored + // row: addToLocalComponentStore strips the transient + // relativeX/relativeY (not DB columns), so strip them here + // too — otherwise a redo in persisted mode would insert + // those non-schema fields and fail. + const { + relativeX: _rx, + relativeY: _ry, + ...storedShape + } = memberData + pasteBatchEntries.push({ + action: 'ADD', + id: c.id, + componentInfo: storedShape as ComponentRecord, + }) + }) + if (pasteBatchEntries.length > 0) { + recordBatchToHistoryLog(pasteBatchEntries) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const newGroup: any = { id: generateUUID(), @@ -268,8 +367,28 @@ export function useCanvasClipboard({ fill: null, stroke: null, children: newChildren, + // Hide the just-persisted standalones beneath the overlay in + // the same update that paints the group's copies (atomic + // swap — see groupobject.tsx). Because the standalones now + // exist in the scene, the group's blur handler takes the + // restore-opacity path (foundOriginalCount > 0) rather than + // re-materialising them: no double-write, no flicker. + membersToHide: memberIds, + } + + // Standalones mount asynchronously (React.lazy). Wait until the + // last one is in the scene before rendering the overlay so the + // group's atomic hide finds them — no brief double-paint of a + // standalone plus its overlay copy. Falls back to immediate + // render if there's nothing to wait on. + const lastId = memberIds[memberIds.length - 1] + if (lastId && twoJSInstance) { + pollUntilElement(twoJSInstance, lastId, () => { + renderGroupRef.current?.([newGroup]) + }) + } else { + renderGroupRef.current?.([newGroup]) } - renderGroupRef.current?.([newGroup]) } } window.addEventListener('keydown', onPasteEvent) diff --git a/src/hooks/useComponentHistory.ts b/src/hooks/useComponentHistory.ts index 20724f0..3996282 100644 --- a/src/hooks/useComponentHistory.ts +++ b/src/hooks/useComponentHistory.ts @@ -174,9 +174,18 @@ function applyPropertyToTwoJSGroup( Object.entries(value as Record).forEach( ([k, v]) => { if (k === 'opacity') { - // Opacity lives on the leaf shape (children[0]) by - // codebase convention; matches applyGroupProperty. - shape.opacity = v + // Opacity is applied at the GROUP level (see + // applyProperty and the *-with-text components) so + // the shape + text dim uniformly and repaint + // reliably. Reset the leaf/text so they don't + // compound with the group's opacity. + group.opacity = v + shape.opacity = 1 + if (textNodes.length > 0) { + textNodes.forEach( + (n: ShapeLike) => (n.opacity = 1) + ) + } } else if ( k === 'textFontSize' || k === 'fontSize' @@ -471,6 +480,26 @@ export function useComponentHistory({ reapplyTextFromMeta(group, props.metadata) } + // Connector port bindings have no Two.js geometry of their own, so + // applyPropertyToTwoJSGroup skips them. Mirror them onto elementData + // here so undo/redo of a detach restores (or re-clears) the binding + // that port re-anchoring reads. + if (group.elementData) { + const bindingKeys = [ + 'tailShapeId', + 'tailEdge', + 'headShapeId', + 'headEdge', + ] as const + bindingKeys.forEach((k) => { + if (k in props) { + group.elementData[k] = ( + props as Record + )[k] + } + }) + } + two?.update() if (props.width !== undefined || props.height !== undefined) { @@ -760,6 +789,18 @@ export function useComponentHistory({ // Only UPDATE_VERTICES and UPDATE_BULK need extra capture — for // ADD/DELETE/BATCH the original entry already contains everything redo needs. const captureNextState = (entry: HistoryEntry): HistoryEntry => { + if (entry.action === 'ADD') { + // The ADD entry's componentInfo is snapshotted at create time. For + // arrows (and any element whose post-create geometry is applied with + // skipHistory), that snapshot is stale — e.g. an arrow is pre-created + // off-screen at -9999 with zero-length vertices, then drawn later. + // Re-read the live store here (still present, since undo's + // applyRemove runs after this) so redo re-inserts the final geometry. + const current = stateRefForComponentStore.current[entry.id] + return current + ? { ...entry, componentInfo: { ...current } } + : entry + } if (entry.action === 'UPDATE_VERTICES') { const current = stateRefForComponentStore.current[entry.id] return { @@ -811,6 +852,12 @@ export function useComponentHistory({ const updatedBucket = [...bucketLogRef.current, enrichedForRedo] writeBucket(updatedBucket) + + // An active group overlay shows static copies of its members, so it + // can't reflect an undo that moved/removed them underneath. Signal it to + // dismiss (reveal the now-updated real members + drop the overlay). Sent + // after applyBatch so members are already at their reverted state. + window.dispatchEvent(new CustomEvent('historyApplied')) } const redoLastAction = (): void => { @@ -851,6 +898,10 @@ export function useComponentHistory({ delete cleanEntry.nextProps const updatedLog = [...historyLogRef.current, cleanEntry as HistoryEntry] writeHistory(updatedLog) + + // See undoLastAction: dismiss any active group overlay so it can't show + // stale copies of members a redo just moved/re-added. + window.dispatchEvent(new CustomEvent('historyApplied')) } const clearHistory = ( diff --git a/src/hooks/useConnectorsEnabled.ts b/src/hooks/useConnectorsEnabled.ts new file mode 100644 index 0000000..d7ca47f --- /dev/null +++ b/src/hooks/useConnectorsEnabled.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react' +import { + getConnectorsEnabled, + setConnectorsEnabled, + subscribeConnectorsEnabled, +} from '../utils/featureFlags' + +// React binding for the live connectors feature flag. Returns the current value +// and a setter; re-renders when the flag changes from anywhere (other tabs of +// the same component, the Settings modal, etc.). +export function useConnectorsEnabled(): [boolean, (enabled: boolean) => void] { + const [enabled, setEnabled] = useState(getConnectorsEnabled) + + useEffect(() => subscribeConnectorsEnabled(setEnabled), []) + + return [enabled, setConnectorsEnabled] +} diff --git a/src/hooks/useElementDefaults.ts b/src/hooks/useElementDefaults.ts index 10bf788..516fd22 100644 --- a/src/hooks/useElementDefaults.ts +++ b/src/hooks/useElementDefaults.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' const STORAGE_KEY = 'craftbase:elementDefaults' @@ -26,7 +27,7 @@ const INITIAL_DEFAULTS: ElementDefaultsState = { defaultOpacity: 1, defaultTextColor: '#1A1612', defaultTextSize: 'M', - defaultTextFontFamily: 'Caveat', + defaultTextFontFamily: DEFAULT_TEXT_FONT_FAMILY, } function loadFromStorage(): ElementDefaultsState { @@ -59,6 +60,9 @@ export interface ElementDefaultsApi extends ElementDefaultsState { setDefaultTextColorInBoard: (val: string) => void setDefaultTextSizeInBoard: (val: TextSizeLabel) => void setDefaultTextFontFamilyInBoard: (val: string) => void + // Restore every default to its factory value (used by "clear canvas" so a + // leaked default like linewidth:0 doesn't carry over to the next drawing). + resetDefaults: () => void } export function useElementDefaults(): ElementDefaultsApi { @@ -137,6 +141,19 @@ export function useElementDefaults(): ElementDefaultsApi { const setDefaultTextFontFamilyInBoard = (val: string): void => setDefaultTextFontFamily(val) + // Reset all defaults to INITIAL_DEFAULTS. The persistence effect above then + // rewrites localStorage to the factory values on the resulting state change. + const resetDefaults = (): void => { + setDefaultFill(INITIAL_DEFAULTS.defaultFill) + setDefaultStrokeColor(INITIAL_DEFAULTS.defaultStrokeColor) + setDefaultLinewidth(INITIAL_DEFAULTS.defaultLinewidth) + setDefaultStrokeType(INITIAL_DEFAULTS.defaultStrokeType) + setDefaultOpacity(INITIAL_DEFAULTS.defaultOpacity) + setDefaultTextColor(INITIAL_DEFAULTS.defaultTextColor) + setDefaultTextSize(INITIAL_DEFAULTS.defaultTextSize) + setDefaultTextFontFamily(INITIAL_DEFAULTS.defaultTextFontFamily) + } + return { defaultFill, defaultStrokeColor, @@ -162,5 +179,6 @@ export function useElementDefaults(): ElementDefaultsApi { setDefaultTextColorInBoard, setDefaultTextSizeInBoard, setDefaultTextFontFamilyInBoard, + resetDefaults, } } diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 20866c1..54b79b3 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -12,6 +12,7 @@ import React, { useEffect, useState, useRef, + useCallback, Suspense, type MutableRefObject, type ReactNode, @@ -49,15 +50,32 @@ import { GEO_DRAW_PROPS_KEY, GEO_POINT_PLACE_MODE_KEY, GEO_MIN_VERTICES, + DEFAULT_TEXT_FONT_FAMILY, } from './constants/misc' import Spinner from './components/common/spinner' -const elementModules = import.meta.glob('./components/elements/*.tsx') +// Shared lazy-element glob (single source of truth) so the chunk warmed by +// prefetchElementModule is the exact one React.lazy mounts here. +import { elementModules } from './elementModules' import Loader from './components/utils/loader' -import SelectionController from './canvas/selectionController' +import SelectionController, { + PORT_GAP, + PORT_RADAR_RADIUS, + SELECTION_PADDING, +} from './canvas/selectionController' import { updateX1Y1Vertices, updateX2Y2Vertices } from './utils/updateVertices' +import { + getShapePortPoint, + findNearestPort, + getStackedPortPoint, + PORT_TAIL_STACK_GAP, +} from './utils/shapePorts' import { generateUUID } from './utils/misc' +import { + getConnectorsEnabled, + subscribeConnectorsEnabled, +} from './utils/featureFlags' import { velocityToLinewidth, smoothLinewidth, @@ -78,6 +96,9 @@ import { growShapeToFitText, usableTextWidth } from './utils/shapeTextFit' import { isSelectPanMode, isPanMode } from './utils/drawModeUtils' import { createDiamondPath } from './factory/diamond' import { useCanvasClipboard } from './hooks/useCanvasClipboard' +import type { HistoryEntry } from './hooks/useComponentHistory' +import { exportSelectionAsSvg } from './utils/exportSelectionAsSvg' +import CanvasContextMenu from './components/canvasContextMenu' import { ElementRenderWrapper, GroupRenderWrapper, @@ -104,6 +125,12 @@ interface CanvasProps { defaultTextSize: number onCameraChange?: (event: CameraChangeEvent) => void renderBackground?: () => ReactNode + // Bridge: Canvas owns reorderSelected (needs reconcileZOrder + live zui + // selection); it publishes the function here so board.tsx can expose a + // stable wrapper through BoardContext for the properties toolbar. + reorderSelectedRef?: MutableRefObject< + ((op: 'front' | 'forward' | 'backward' | 'back') => void) | null + > } // Shape of the handle addZUI returns and Canvas stores in state. The @@ -155,6 +182,40 @@ let defaultStrokeColorValue: string = PENCIL_DEFAULT_COLOR // (same stale-closure escape hatch as defaultLinewidthValue). let defaultTextSizeValue: number = DEFAULT_TEXT_SIZE +// --- z-order reconcile helpers ------------------------------------------ +// +// The persistable element groups inside `two.scene.children` are the ones we +// reorder. Everything else (selection-box overlay, preview dots/lines, the +// transient `groupobject` group) must be left untouched. An element group is +// identified by carrying `elementData.id` that maps to a live store record and +// is not the transient GROUP_COMPONENT. The selection overlay is a plain group +// with no `elementData`, so it's excluded automatically. +const isReorderableElementChild = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + child: any, + store: ComponentStore +): boolean => { + const id = child?.elementData?.id + if (!id) return false + if (child.elementData.componentType === GROUP_COMPONENT) return false + return Object.prototype.hasOwnProperty.call(store, id) +} + +// Stable ordering key for a record: position asc (back→front), tie-broken by +// createdAt then id so legacy/duplicate positions still sort deterministically +// (and neighbour-swap stays meaningful). Used by both the reconcile comparator +// and the reorder handlers so they agree on "the element above/below". +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const compareByZOrder = (a: any, b: any): number => { + const pa = Number.isFinite(a?.position) ? a.position : 0 + const pb = Number.isFinite(b?.position) ? b.position : 0 + if (pa !== pb) return pa - pb + const ca = Number.isFinite(a?.createdAt) ? a.createdAt : 0 + const cb = Number.isFinite(b?.createdAt) ? b.createdAt : 0 + if (ca !== cb) return ca - cb + return String(a?.id ?? '').localeCompare(String(b?.id ?? '')) +} + function addZUI( props: CanvasProps, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -262,6 +323,30 @@ function addZUI( // eslint-disable-next-line @typescript-eslint/no-explicit-any let arrowDrawElement: any = null + // Source shape of a port-pulled connector, so the radar excludes its own + // ports (a connector can't dock back onto the shape it departed). Null for + // free arrows drawn from the toolbar. + let arrowDrawTailShapeId: string | null = null + // Stacking state for a port-pulled connector: the source port anchor, its + // edge, and this connector's slot among the arrows already leaving that port + // (0 = first, no fan). The tail is re-fanned every mousemove so its offset + // direction tracks whichever quadrant the head is dragged into. + let arrowDrawTailPort: { + anchor: { x: number; y: number } + edge: string + index: number + } | null = null + // Phase-2 magnetic snap: while an arrow endpoint is being dragged and the + // radar has it magnetically docked on a port, this holds the would-be + // binding. It's recomputed every mousemove frame (null when not docked) and + // committed on release — so if the user pulls away before letting go, no + // connection is made. + let pendingPortConnection: { + arrowId: string + endpoint: 'head' | 'tail' + edge: string + shapeId: string + } | null = null // eslint-disable-next-line @typescript-eslint/no-explicit-any let textDrawElement: any = null // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -470,6 +555,15 @@ function addZUI( }, commit: (id, patch) => { updateComponentBulkPropertiesInLocalStore(id, patch) + // Resize ended — persist any connectors whose ports tracked the + // shape so their new tail/head survive a reload. + const g = selectionController.currentGroup + if (g) persistBoundArrows(g) + }, + onTransform: (group) => { + // Live-follow during scale/rotate: drag bound connectors with the + // shape's edges. + reanchorArrowsForShape(group) }, onDelete: (group) => { const id = group?.elementData?.id @@ -501,6 +595,15 @@ function addZUI( activeGroupRef.current = null }) + // Connectors flag is live-toggleable from Settings. When it flips, re-sync + // the current selection box so its edge ports appear/disappear immediately + // (rather than waiting for the next transform). If the flag goes off mid + // arrow-drag, clear any lingering radar glow too. + subscribeConnectorsEnabled((enabled) => { + if (!enabled) selectionController.hidePortGlow() + selectionController.resync() + }) + function dblclick(e: MouseEvent) { // In a multi-click geo draw, a double-click finishes it. Drop the // duplicate vertex the second mousedown added. @@ -601,16 +704,18 @@ function addZUI( // Two.js renders text at `fontSize * sceneScale` screen pixels. // Match the textarea/measureSpan to that so visuals stay in sync // and the surface-unit math (measuredW / zoom) remains correct. + // Camera-dependent geometry — reassigned by recomputeGeometry() on + // every two.update (pan/zoom) so the editor stays glued to the shape. const sceneScale = two?.scene?.scale || 1 - const cssFontSize = fontSize * sceneScale + let cssFontSize = fontSize * sceneScale // Use a generous line-height so ascenders/descenders are // never clipped. A LINE_HEIGHT_MULTIPLIER× covers most font metrics. - const lineH = Math.ceil(cssFontSize * LINE_HEIGHT_MULTIPLIER) + let lineH = Math.ceil(cssFontSize * LINE_HEIGHT_MULTIPLIER) // Vertical padding inside the textarea prevents the top of // tall glyphs (H, d, l …) from being cut off by the element // boundary. Half the difference between lineH and cssFontSize // approximates the ascender headroom the browser needs. - const vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + let vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 const input = document.createElement('textarea') const randomId = Math.floor(Math.random() * 90 + 10) @@ -627,7 +732,8 @@ function addZUI( input.style.padding = `${vertPad}px 8px` input.style.color = textStyle.fill || '#3A342C' input.style.fontSize = `${cssFontSize}px` - input.style.fontFamily = textStyle.family || 'Caveat' + input.style.fontFamily = + textStyle.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = String(textStyle.weight ?? 'normal') input.style.lineHeight = `${lineH}px` input.style.letterSpacing = '0px' @@ -643,16 +749,18 @@ function addZUI( input.style.boxSizing = 'border-box' input.className = 'temp-input-area' - // Anchor point: the SVG text element's screen-space center - const centerX = screenRect.left + screenRect.width / 2 - const centerY = screenRect.top + screenRect.height / 2 + // Anchor point: the SVG shape's screen-space center. The shape stays + // VISIBLE during edit (only the text layer is hidden), so its rect is + // live — recomputeGeometry() re-reads it each frame to follow pan/zoom. + let centerX = screenRect.left + screenRect.width / 2 + let centerY = screenRect.top + screenRect.height / 2 // px-per-surface-unit derived from the shape's current screen // size; converts the textarea's pixel measurement back into // Two.js surface units before growing the shape. const rectScreen = rectChild?._renderer?.elem?.getBoundingClientRect() - const zoom = + let zoom = rectChild && rectScreen && rectChild.width ? rectScreen.width / rectChild.width : 1 @@ -668,7 +776,7 @@ function addZUI( // (screen px), so wrapping mirrors the committed render and the // box never spills outside the shape horizontally. const surfaceW = rectChild?.width || screenRect.width / zoom - const usableScreenW = Math.max( + let usableScreenW = Math.max( Math.round(usableTextWidth(shapeKind, surfaceW) * zoom), Math.ceil(cssFontSize) // never below ~1 glyph ) @@ -682,7 +790,8 @@ function addZUI( measureSpan.style.overflowWrap = 'anywhere' measureSpan.style.width = `${usableScreenW}px` measureSpan.style.fontSize = `${cssFontSize}px` - measureSpan.style.fontFamily = textStyle.family || 'Caveat' + measureSpan.style.fontFamily = + textStyle.family || DEFAULT_TEXT_FONT_FAMILY measureSpan.style.fontWeight = String(textStyle.weight ?? 'normal') measureSpan.style.lineHeight = `${lineH}px` measureSpan.style.letterSpacing = '0px' @@ -690,8 +799,43 @@ function addZUI( measureSpan.style.boxSizing = 'content-box' document.body.appendChild(measureSpan) - const autoSizeAndCenter = () => { - // Measure wrapped height at the fixed usable width. + // Pull anchor + font from the LIVE shape rect + camera. Called on + // every two.update so the editor pans/zooms with the shape. + const recomputeGeometry = () => { + const scale = two?.scene?.scale || 1 + cssFontSize = fontSize * scale + lineH = Math.ceil(cssFontSize * LINE_HEIGHT_MULTIPLIER) + vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + const liveRect = ( + rectChild?._renderer?.elem ?? groupDomElem + )?.getBoundingClientRect() + if (liveRect) { + centerX = liveRect.left + liveRect.width / 2 + centerY = liveRect.top + liveRect.height / 2 + } + const rs = rectChild?._renderer?.elem?.getBoundingClientRect() + zoom = + rectChild && rs && rectChild.width + ? rs.width / rectChild.width + : 1 + const sw = + rectChild?.width || + (liveRect ? liveRect.width / zoom : surfaceW) + usableScreenW = Math.max( + Math.round(usableTextWidth(shapeKind, sw) * zoom), + Math.ceil(cssFontSize) + ) + input.style.fontSize = `${cssFontSize}px` + input.style.lineHeight = `${lineH}px` + input.style.padding = `${vertPad}px 8px` + measureSpan.style.fontSize = `${cssFontSize}px` + measureSpan.style.lineHeight = `${lineH}px` + measureSpan.style.width = `${usableScreenW}px` + } + + // Pure DOM: size + centre the textarea over the shape midpoint. + // No shape-grow / two.update — safe to call from the update handler. + const placeEditor = () => { const val = input.value || 'M' measureSpan.textContent = val const measuredH = measureSpan.offsetHeight @@ -704,36 +848,48 @@ function addZUI( input.style.width = `${contentWidth}px` input.style.height = `${contentHeight}px` - - // Centre over the shape midpoint. Width is fixed to the - // shape's usable width, so the box stays inside the shape; - // only the height grows as lines are added. input.style.left = `${centerX - contentWidth / 2}px` input.style.top = `${centerY - contentHeight / 2}px` + } - // Grow ONLY the shape height to fit the wrapped lines - // (width is user-driven). Symmetric growth keeps the centre - // fixed, so centerX/centerY stay valid. - if (rectChild) { - const textSurfaceH = measuredH / zoom - const { h: nextH } = growShapeToFitText( - shapeKind, - rectChild.width, - rectChild.height, - 0, - textSurfaceH - ) - if (rectChild.height < nextH) { - rectChild.height = nextH - two.update() - } + // Grow ONLY the shape height to fit the wrapped lines (width is + // user-driven). Symmetric growth keeps the centre fixed. Only on + // typing — NOT from the update handler (it calls two.update). + const growShapeToFit = () => { + if (!rectChild) return + const val = input.value || 'M' + measureSpan.textContent = val + const measuredH = measureSpan.offsetHeight + const textSurfaceH = measuredH / zoom + const { h: nextH } = growShapeToFitText( + shapeKind, + rectChild.width, + rectChild.height, + 0, + textSurfaceH + ) + if (rectChild.height < nextH) { + rectChild.height = nextH + two.update() } } - autoSizeAndCenter() + // Re-glue the editor to the shape after any render (pan/zoom). + const repositionEditor = () => { + recomputeGeometry() + placeEditor() + } + + const onTextInput = () => { + growShapeToFit() // may two.update → 'update' → repositionEditor + placeEditor() + } + + repositionEditor() + two.bind('update', repositionEditor) // Re-measure on every keystroke so the box grows with the text - input.addEventListener('input', autoSizeAndCenter) + input.addEventListener('input', onTextInput) input.focus() @@ -756,8 +912,9 @@ function addZUI( }) input.addEventListener('blur', () => { - // Clean up the input listener and measurement span - input.removeEventListener('input', autoSizeAndCenter) + // Clean up the camera tracker, input listener and measure span + two.unbind('update', repositionEditor) + input.removeEventListener('input', onTextInput) if (measureSpan.parentNode) { measureSpan.parentNode.removeChild(measureSpan) } @@ -941,6 +1098,499 @@ function addZUI( lastHoveredCircleGroup = found ?? null } + // Outward offset (surface units) from a shape's edge to its floated + // selection port, matching where selectionController draws the port dot: + // the box padding plus the screen-constant port gap divided out by zoom. + const portGapSurface = (): number => + SELECTION_PADDING + PORT_GAP / (zui.scale || 1) + + // Surface-space step between stacked connector tails (screen-constant, so it + // matches the port-dot spacing at the current zoom). + const portTailStackGapSurface = (): number => + PORT_TAIL_STACK_GAP / (zui.scale || 1) + + // How many connectors already leave `shapeId`'s `edge` port (tail bound + // there). A fresh pull-out takes the next slot, so it fans out beyond them. + function countPortConnectors( + shapeId: string | undefined, + edge: string + ): number { + if (!shapeId) return 0 + let n = 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((c: any) => { + const ed = c?.elementData + if ( + ed?.componentType === 'arrowLine' && + ed.tailShapeId === shapeId && + ed.tailEdge === edge + ) { + n++ + } + }) + return n + } + + // Re-fan a port-bound endpoint (tail = vertex[0], head = vertex[1]): place + // it at the stacked offset for this connector's slot, fanning toward the + // side its OTHER endpoint currently sits on. `port` is the live port anchor; + // the far endpoint is read off the line so the fan direction follows the + // drag (or the bound far end after a shape move). + function applyStackedEndpoint( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrowGroup: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + line: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + circle: any, + edge: string, + port: { x: number; y: number }, + index: number, + endpoint: 'tail' | 'head' + ) { + const farIdx = endpoint === 'tail' ? 1 : 0 + const far = { + x: arrowGroup.position.x + line.vertices[farIdx].x, + y: arrowGroup.position.y + line.vertices[farIdx].y, + } + const pt = getStackedPortPoint( + edge, + port, + far, + index, + portTailStackGapSurface() + ) + const relX = pt.x - arrowGroup.position.x + const relY = pt.y - arrowGroup.position.y + if (endpoint === 'tail') { + updateX1Y1Vertices(Two, line, relX, relY, circle, two) + } else { + updateX2Y2Vertices(Two, line, relX, relY, circle, two) + } + } + + // Which arrow endpoint a drag is moving — context for the magnetic snap. + type PortDragContext = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrowGroup: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + line: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + circle: any + endpoint: 'head' | 'tail' + } + + // Radar: while an arrow endpoint is dragging at `probeSurface` (the cursor), + // glow + outline the nearest landable port. When `dragContext` is supplied + // and a port is in range, also do the one-off magnetic pull — snap that + // endpoint onto the port and remember the would-be binding in + // `pendingPortConnection` (committed on release). The probe is the cursor, + // not the snapped point, so pulling past the threshold releases the magnet + // and clears the pending binding — the user is never forced to connect. + function updatePortRadar( + probeSurface: { x: number; y: number }, + dragContext: PortDragContext | null = null, + excludeShapeId: string | null = arrowDrawTailShapeId + ) { + // No port snapping/glow while connectors are disabled (live flag). + if (!getConnectorsEnabled()) return + const threshold = PORT_RADAR_RADIUS / (zui.scale || 1) + const nearest = findNearestPort( + two.scene.children, + probeSurface, + threshold, + portGapSurface(), + excludeShapeId + ) + if (nearest) { + // Pass the candidate group so the controller also outlines the + // shape the connector would attach to. + selectionController.showPortGlow(nearest.point, nearest.group) + if (dragContext && nearest.shapeId) { + snapEndpointToPort(dragContext, nearest.point) + pendingPortConnection = { + arrowId: dragContext.arrowGroup?.elementData?.id, + endpoint: dragContext.endpoint, + edge: nearest.edge, + shapeId: nearest.shapeId, + } + } + } else { + selectionController.hidePortGlow() + // Just undocked: the magnet overwrote the endpoint with the port's + // absolute position, but the endpoint-drag path advances the vertex + // incrementally — so re-place it exactly under the cursor, else it + // keeps the dock offset after release. (Harmless for the pull-out + // path, which already recomputes the head from the cursor.) + if (pendingPortConnection && dragContext) { + snapEndpointToPort(dragContext, probeSurface) + } + pendingPortConnection = null + } + } + + // Magnetic pull: glue the dragged endpoint to the floated port anchor by + // rewriting its line vertex (and endpoint circle) to that surface point. + function snapEndpointToPort( + ctx: PortDragContext, + point: { x: number; y: number } + ) { + const relX = point.x - ctx.arrowGroup.translation.x + const relY = point.y - ctx.arrowGroup.translation.y + if (ctx.endpoint === 'head') { + updateX2Y2Vertices(Two, ctx.line, relX, relY, ctx.circle, two) + } else { + updateX1Y1Vertices(Two, ctx.line, relX, relY, ctx.circle, two) + } + } + + // Commit a magnetic snap that was still active at release: write the + // tail/head binding onto the arrow (live + store) so the endpoint stays + // glued to the port and re-anchors when that shape later moves. Used by the + // pull-out path; the endpoint-drag path folds the same write into its own + // bulk update so detach/connect share a single undo step. + function applyPendingPortConnection() { + const pc = pendingPortConnection + if (!pc || !pc.arrowId || !pc.shapeId) return + const arrowGroup = two.scene.children.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => c?.elementData?.id === pc.arrowId + ) + const ed = arrowGroup?.elementData + if (!ed) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const patch: Record = {} + if (pc.endpoint === 'head') { + ed.headShapeId = pc.shapeId + ed.headEdge = pc.edge + patch.headShapeId = pc.shapeId + patch.headEdge = pc.edge + } else { + ed.tailShapeId = pc.shapeId + ed.tailEdge = pc.edge + patch.tailShapeId = pc.shapeId + patch.tailEdge = pc.edge + } + updateComponentBulkPropertiesInLocalStore(pc.arrowId, patch) + } + + // Pull a connector arrow out of a selection port. Mirrors the toolbar's + // arrow-create flow (off-screen arrowLine in the store, then drive it via + // SCENARIO_ARROW_DRAW), except the tail is pinned to the port's surface + // anchor instead of the next mousedown's cursor, and the drag starts + // immediately so the user only has to drop the head. The tail's binding + // (tailShapeId/tailEdge) is recorded so it re-anchors when the shape moves. + function startPortConnector( + anchor: { x: number; y: number }, + tailShapeId: string | undefined, + tailEdge: string + ) { + const arrowId = generateUUID() + const userId = localStorage.getItem('userId') + // Slot among the connectors already leaving this port — drives the fan + // so this new tail doesn't bunch onto the existing ones. + const tailPortIndex = countPortConnectors(tailShapeId, tailEdge) + const arrowData = { + id: arrowId, + componentType: 'arrowLine', + linewidth: defaultLinewidthValue, + strokeType: defaultStrokeTypeValue, + stroke: defaultStrokeColorValue ?? '#3A342C', + children: {}, + x: -9999, + y: -9999, + x1: 0, + x2: 0, + y1: 0, + y2: 0, + boardId: props.boardId, + boardName: null, + radius: null, + iconStroke: null, + isDummy: null, + createdAt: null, + metadata: { opacity: 1 }, + width: 100, + height: 0, + fill: 'transparent', + textColor: null, + updatedBy: userId, + // Connection binding — tail pinned to the source shape's edge port. + tailShapeId: tailShapeId ?? null, + tailEdge, + tailPortIndex, + headShapeId: null, + headEdge: null, + } + addToLocalComponentStore( + arrowId, + 'arrowLine', + arrowData as unknown as ComponentRecord + ) + + // Remember the source so the radar won't glow this shape's own ports. + arrowDrawTailShapeId = tailShapeId ?? null + // Remember the port so each mousemove can re-fan the tail (direction + // follows the head's quadrant; magnitude is this connector's slot). + arrowDrawTailPort = { + anchor: { x: anchor.x, y: anchor.y }, + edge: tailEdge, + index: tailPortIndex, + } + // Fresh drag — no magnetic dock yet. + pendingPortConnection = null + + // Drop the source shape's selection box so it doesn't linger over the + // new connector while dragging. + selectionController.detach() + + scenario = SCENARIO_ARROW_DRAW + domElement.addEventListener('mousemove', mousemove, false) + domElement.addEventListener('mouseup', mouseup, false) + setRootCursor('crosshair') + + // The arrowLine React element mounts lazily — poll until it appears, + // then pin the tail to the port anchor and collapse the head onto it so + // the upcoming mousemove stretches it from the port. + pollUntilElement( + two, + arrowId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (el: any) => { + arrowDrawElement = el + arrowDrawElement.position.x = anchor.x + arrowDrawElement.position.y = anchor.y + + const line = arrowDrawElement.children[0] + const pointCircle1Group = arrowDrawElement.children[1] + const pointCircle2Group = arrowDrawElement.children[2] + + updateX1Y1Vertices(Two, line, 0, 0, pointCircle1Group, two) + updateX2Y2Vertices(Two, line, 0, 0, pointCircle2Group, two) + two.update() + }, + { maxRetries: 30 } + ) + } + + // Re-anchor every connector bound to `group`: recompute its tail (and/or + // head) so the bound endpoint stays glued to the shape's edge port. Called + // live during a shape drag so the connector follows in real time. The arrow + // group's own position stays put — only the bound vertex moves, which keeps + // the free endpoint fixed in surface space. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function reanchorArrowsForShape(group: any) { + // Bound arrows only track their shape while connectors are enabled. When + // off, existing bindings lie dormant (the arrow stays put) rather than + // being stripped — flip the flag back on to resume gluing. + if (!getConnectorsEnabled()) return + const shapeId = group?.elementData?.id + if (!shapeId) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + if (child?.elementData?.componentType !== 'arrowLine') return + const ed = child.elementData + const line = child.children?.[0] + if (!line) return + if (ed.tailShapeId === shapeId && ed.tailEdge) { + const p = getShapePortPoint(group, ed.tailEdge, portGapSurface()) + if (ed.tailPortIndex > 0) { + // Keep the fan offset (direction follows the bound head). + applyStackedEndpoint( + child, + line, + child.children[1], + ed.tailEdge, + p, + ed.tailPortIndex, + 'tail' + ) + } else { + updateX1Y1Vertices( + Two, + line, + p.x - child.position.x, + p.y - child.position.y, + child.children[1], + two + ) + } + } + if (ed.headShapeId === shapeId && ed.headEdge) { + const p = getShapePortPoint(group, ed.headEdge, portGapSurface()) + if (ed.headPortIndex > 0) { + // Keep the fan offset (direction follows the bound tail). + applyStackedEndpoint( + child, + line, + child.children[2], + ed.headEdge, + p, + ed.headPortIndex, + 'head' + ) + } else { + updateX2Y2Vertices( + Two, + line, + p.x - child.position.x, + p.y - child.position.y, + child.children[2], + two + ) + } + } + }) + } + + // Persist the (already re-anchored) vertices of connectors bound to `group` + // so the new tail/head survive a reload. Mirrors the arrow-draw commit: + // prevX === position.x means the x,y branch is a no-op (the arrow group never + // moves), and isLineCircle drives the x1/y1/x2/y2 write. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function persistBoundArrows(group: any) { + if (!getConnectorsEnabled()) return + const shapeId = group?.elementData?.id + if (!shapeId) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + if (child?.elementData?.componentType !== 'arrowLine') return + const ed = child.elementData + if (ed.tailShapeId !== shapeId && ed.headShapeId !== shapeId) return + const line = child.children?.[0] + if (!line) return + updateToGlobalState( + { + id: ed.id, + prevX: parseInt(child.position.x), + prevY: parseInt(child.position.y), + isLineCircle: true, + parentData: child, + data: { + x: parseInt(child.position.x), + y: parseInt(child.position.y), + x1: parseInt(line.vertices[0].x), + y1: parseInt(line.vertices[0].y), + x2: parseInt(line.vertices[1].x), + y2: parseInt(line.vertices[1].y), + }, + }, + {} + ) + }) + } + + // After a connector endpoint settles on a port, re-sort EVERY endpoint + // docked at that port (tails pulled out of it AND heads dropped onto it) so + // their order matches their far endpoints' order: for side edges (e/w) by + // the far end's y, for top/bottom edges (n/s) by its x. Without this the + // slot index is frozen when the binding is made, so a later connector whose + // far end lands between two earlier ones still stacks past them. The + // endpoint whose far end is closest to the port-normal axis keeps the bare + // port point (index 0); the rest fan out by side in far-end order. + // Re-applies the fan and persists the new vertices + index so a reload + // (local mode) keeps the layout. + function restackPortConnectors(shapeId: string | undefined, edge: string) { + if (!shapeId) return + const group = two.scene.children.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => c?.elementData?.id === shapeId + ) + if (!group) return + const port = getShapePortPoint(group, edge, portGapSurface()) + const sideEdge = edge === 'e-resize' || edge === 'w-resize' + const center = sideEdge ? port.y : port.x + + type Entry = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + child: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + line: any + endpoint: 'tail' | 'head' + coord: number + dist: number + } + const entries: Entry[] = [] + // Far endpoint vertex index for ordering: a tail bound here is ordered + // by its head (vertex[1]); a head bound here by its tail (vertex[0]). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pushEntry = (child: any, line: any, endpoint: 'tail' | 'head') => { + const farIdx = endpoint === 'tail' ? 1 : 0 + const coord = sideEdge + ? child.position.y + line.vertices[farIdx].y + : child.position.x + line.vertices[farIdx].x + entries.push({ + child, + line, + endpoint, + coord, + dist: Math.abs(coord - center), + }) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + const ed = child?.elementData + if (ed?.componentType !== 'arrowLine') return + const line = child.children?.[0] + if (!line) return + if (ed.tailShapeId === shapeId && ed.tailEdge === edge) + pushEntry(child, line, 'tail') + if (ed.headShapeId === shapeId && ed.headEdge === edge) + pushEntry(child, line, 'head') + }) + if (entries.length === 0) return + + // Closest-to-center first: the nearest takes the bare port (index 0), + // the rest fan outward per side, increasing with distance — which keeps + // endpoint order aligned with far-end order within each side. + entries.sort((a, b) => a.dist - b.dist) + let beforeCount = 0 + let afterCount = 0 + entries.forEach((entry, i) => { + const index = + i === 0 + ? 0 + : entry.coord < center + ? ++beforeCount + : ++afterCount + const circle = + entry.endpoint === 'tail' + ? entry.child.children[1] + : entry.child.children[2] + if (entry.endpoint === 'tail') + entry.child.elementData.tailPortIndex = index + else entry.child.elementData.headPortIndex = index + applyStackedEndpoint( + entry.child, + entry.line, + circle, + edge, + port, + index, + entry.endpoint + ) + const patch = + entry.endpoint === 'tail' + ? { + x1: parseInt(entry.line.vertices[0].x), + y1: parseInt(entry.line.vertices[0].y), + tailPortIndex: index, + } + : { + x2: parseInt(entry.line.vertices[1].x), + y2: parseInt(entry.line.vertices[1].y), + headPortIndex: index, + } + updateComponentBulkPropertiesInLocalStore( + entry.child.elementData.id, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + patch as any, + // Cosmetic re-layout — don't spawn an undo step per moved arrow. + true + ) + }) + } + function mousedown(e: MouseEvent) { // Pan-mode (desktop): grab-and-drag translates the surface instead of // selecting/drawing. Runs before everything else so a click on a shape @@ -990,6 +1640,27 @@ function addZUI( } }) } else if (selectionController.currentGroup) { + // Port check first: a click on a connection port pulls out a + // connector arrow (tail pinned to the port) instead of selecting or + // resizing. Ports sit outside the box, so they never overlap the + // resize handles below. + const portHit = selectionController.hitTestPort( + e.clientX, + e.clientY + ) + if (portHit) { + const sourceGroup = selectionController.currentGroup + const tailShapeId = sourceGroup?.elementData?.id + // Pin the tail to the floated selection port (edge + padding + + // port gap) so the connector visually starts at the port dot. + const anchor = getShapePortPoint( + sourceGroup, + portHit.edge, + portGapSurface() + ) + startPortConnector(anchor, tailShapeId, portHit.edge) + return + } // Controller handle check — runs before the bare-canvas clearSelector // dispatch and the DOM path walk. Corner handles can extend slightly // beyond the element's SVG bounds, so relying on path-walking would @@ -1507,6 +2178,23 @@ function addZUI( ? shape.elementData.lineData : shape.children[0] + // Keep a selected arrow's endpoint handles visible. The + // mousedown above hides ALL endpoints (line ~1632); the + // pull-out auto-select and the hover fallback re-show them, + // but a plain body-click never did — leaving the handles + // at opacity 0. Since opacity-0 circles don't fire pointer + // events, a follow-up grab of an endpoint would miss the + // circle, fall through to the null→parent-arrow fallback, + // and become a whole-arrow BODY drag — which detaches the + // arrow's port bindings. Re-showing them here keeps the next + // endpoint grab an endpoint drag, so bindings survive. + if ( + groupForToolbar?.elementData?.componentType === + 'arrowLine' + ) { + setArrowEndpointsVisible(groupForToolbar, true) + } + // First line node of the text layer (or a legacy direct // text child) — gives the toolbar a representative text // node for shape-with-text enablement. @@ -1605,6 +2293,34 @@ function addZUI( pointCircle2Group, two ) + + // Fan the tail off the port so stacked connectors don't + // overlap — direction follows the quadrant the head is in. + if (arrowDrawTailPort && arrowDrawTailPort.index > 0) { + applyStackedEndpoint( + arrowDrawElement, + line, + arrowDrawElement.children[1], + arrowDrawTailPort.edge, + arrowDrawTailPort.anchor, + arrowDrawTailPort.index, + 'tail' + ) + } + + // Radar sweep + magnetic snap for the head being pulled out. + updatePortRadar( + { + x: arrowDrawElement.position.x + relX, + y: arrowDrawElement.position.y + relY, + }, + { + arrowGroup: arrowDrawElement, + line, + circle: pointCircle2Group, + endpoint: 'head', + } + ) } break case SCENARIO_DRAW_SHAPE: { @@ -1848,11 +2564,33 @@ function addZUI( ) } } + + // Radar + magnetic snap for the endpoint being + // re-dragged (tail or head, per `direction`). Works + // every time, not just at creation. No source + // exclusion here — an existing arrow's end may dock + // onto any shape in range. + updatePortRadar( + toSurface(e), + { + arrowGroup: shape.lineData?.parent, + line: shape.lineData, + circle: shape, + endpoint: + shape.direction === 'left' + ? 'tail' + : 'head', + }, + null + ) } // code block condition to handle normal component's dragging else { shape.position.x += dx / zui.scale shape.position.y += dy / zui.scale + // Drag any connector tails/heads pinned to this + // shape's ports along with it. + reanchorArrowsForShape(shape) } } else if (shape.elementData.isGroupSelector) { // this blocks falls for the case when user has clicked and @@ -1925,6 +2663,11 @@ function addZUI( updateToGlobalState(newShapeData, {}) + // If the head was magnetically docked on a port at release, + // commit the head→shape binding (the vertices were already + // snapped during the drag). + applyPendingPortConnection() + // Auto-select the freshly drawn arrow so its endpoint // handles and the edit toolbar appear immediately — a // visual cue that the arrow is editable. Arrows aren't in @@ -1953,6 +2696,31 @@ function addZUI( setSelectedComponentInBoard(null) } + // Radar is a draw-time affordance only — clear it on drop. + selectionController.hidePortGlow() + // Now the endpoints are firmly placed, re-sort every connector on + // each touched port so their order matches their far ends' order + // (fixes a later connector whose far end lands between earlier + // ones from stacking past them). Two ports may be involved: the + // source port a tail was pulled from, and the port a head docked + // onto (the latter also covers a plain toolbar arrow whose head + // lands on a port — no tail source). + if (arrowDrawTailShapeId && arrowDrawTailPort) { + restackPortConnectors( + arrowDrawTailShapeId, + arrowDrawTailPort.edge + ) + } + const drawnEd = arrowDrawElement?.elementData + if (drawnEd?.headShapeId && drawnEd?.headEdge) { + restackPortConnectors( + drawnEd.headShapeId, + drawnEd.headEdge + ) + } + arrowDrawTailShapeId = null + arrowDrawTailPort = null + arrowDrawElement = null setArrowDrawModeOff() setPointerElement('pointer') @@ -2181,61 +2949,243 @@ function addZUI( } setSelectedComponentInBoard(null) } else if (shape?.elementData) { - if ( - shape.elementData.x !== shape.translation.x || - shape.elementData.y !== shape.translation.y - ) { + // Did the element actually move since mousedown? Compare the + // rounded translation against `prevX`/`prevY` captured at + // mousedown (line ~1721, also `parseInt(translation)`). + // + // The old guard compared `elementData.x` against + // `translation.x` directly, but `elementData.x` is stored as + // a parseInt'd integer while `translation.x` keeps the float. + // For a port-pulled connector the position is a fractional + // port anchor, so the two always differed — making a plain + // CLICK on the arrow body read as a "move" and fall into the + // body branch below, which detaches both port bindings. + // Gating on real movement makes a click a no-op. + const prevX = shape.elementData.prevX + const prevY = shape.elementData.prevY + const movedX = + prevX === undefined + ? shape.elementData.x !== shape.translation.x + : parseInt(shape.translation.x) !== prevX + const movedY = + prevY === undefined + ? shape.elementData.y !== shape.translation.y + : parseInt(shape.translation.y) !== prevY + if (movedX || movedY) { if (shape?.elementData?.isLineCircle === true) { shape.opacity = 0 shape.siblingCircle.opacity = 0 - oldShapeData = { ...shape.elementData } + const vertexObj = { + x1: parseInt(shape.lineData.vertices[0].x), + y1: parseInt(shape.lineData.vertices[0].y), + x2: parseInt(shape.lineData.vertices[1].x), + y2: parseInt(shape.lineData.vertices[1].y), + } - newShapeData = Object.assign( - {}, - shape.elementData, - { - data: { - x1: parseInt( - shape.lineData.vertices[0].x - ), - y1: parseInt( - shape.lineData.vertices[0].y - ), - x2: parseInt( - shape.lineData.vertices[1].x - ), - y2: parseInt( - shape.lineData.vertices[1].y - ), - }, + // Releasing this endpoint either CONNECTS it (still + // magnetically docked on a port at release) or + // DETACHES it (manual move ending off every port). + // Either way fold the binding write into the SAME + // bulk update as the vertices so one undo reverts + // both position and binding. + const arrowGroup = shape.lineData?.parent + const ed = arrowGroup?.elementData + const draggedEndpoint = + shape.direction === 'left' ? 'tail' : 'head' + // Ports this release touches — the one the dragged + // endpoint was on (it may detach or hop off) plus the + // one it docks onto — re-sorted once the binding is + // settled so the remaining fans stay ordered. + const portsToRestack: { + shapeId: string + edge: string + }[] = [] + const recordPort = ( + shapeId?: string | null, + edge?: string | null + ) => { + if ( + shapeId && + edge && + !portsToRestack.some( + (p) => + p.shapeId === shapeId && + p.edge === edge + ) + ) + portsToRestack.push({ shapeId, edge }) + } + if (ed && draggedEndpoint === 'tail') + recordPort(ed.tailShapeId, ed.tailEdge) + else if (ed && draggedEndpoint === 'head') + recordPort(ed.headShapeId, ed.headEdge) + const connectHere = + !!pendingPortConnection && + pendingPortConnection.endpoint === + draggedEndpoint && + !!pendingPortConnection.shapeId && + !!ed && + pendingPortConnection.arrowId === ed.id + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bindObj: Record = {} + if (connectHere && pendingPortConnection) { + if (draggedEndpoint === 'tail') { + bindObj.tailShapeId = + pendingPortConnection.shapeId + bindObj.tailEdge = + pendingPortConnection.edge + } else { + bindObj.headShapeId = + pendingPortConnection.shapeId + bindObj.headEdge = + pendingPortConnection.edge } - ) + if (ed) Object.assign(ed, bindObj) + } else if ( + draggedEndpoint === 'tail' && + ed && + (ed.tailShapeId || ed.tailEdge) + ) { + bindObj.tailShapeId = null + bindObj.tailEdge = null + bindObj.tailPortIndex = 0 + ed.tailShapeId = null + ed.tailEdge = null + ed.tailPortIndex = 0 + } else if ( + draggedEndpoint === 'head' && + ed && + (ed.headShapeId || ed.headEdge) + ) { + bindObj.headShapeId = null + bindObj.headEdge = null + bindObj.headPortIndex = 0 + ed.headShapeId = null + ed.headEdge = null + ed.headPortIndex = 0 + } - updateToGlobalState(newShapeData, oldShapeData) + if (ed?.id && Object.keys(bindObj).length > 0) { + // One UPDATE_BULK carries vertices + binding so + // undo reverts the whole connect/detach in a + // single step. + updateComponentBulkPropertiesInLocalStore( + ed.id, + { ...vertexObj, ...bindObj } + ) + } else { + oldShapeData = { ...shape.elementData } + newShapeData = Object.assign( + {}, + shape.elementData, + { data: vertexObj } + ) + updateToGlobalState(newShapeData, oldShapeData) + } + + // Re-sort the fans on every port this release touched + // (the port just docked onto + any port the endpoint + // left), now the endpoint is firmly placed. + if (connectHere && pendingPortConnection) + recordPort( + pendingPortConnection.shapeId, + pendingPortConnection.edge + ) + portsToRestack.forEach((p) => + restackPortConnectors(p.shapeId, p.edge) + ) } else { shape.elementData.x = shape.translation.x shape.elementData.y = shape.translation.y - oldShapeData = { ...shape.elementData } + // Dragging a connector's BODY moves both endpoints + // off their ports, so it detaches any bindings — + // otherwise a later shape move would snap the tail + // back. Clear them live and fold into the same + // position update so one undo reverts the whole move. + const ed = shape.elementData + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const arrowDetach: Record = {} + // Ports the body-drag pulled this connector off of, + // re-sorted afterwards so the remaining fans close up. + const bodyDetachPorts: { + shapeId: string + edge: string + }[] = [] + if (ed.componentType === 'arrowLine') { + if (ed.tailShapeId || ed.tailEdge) { + if (ed.tailShapeId && ed.tailEdge) + bodyDetachPorts.push({ + shapeId: ed.tailShapeId, + edge: ed.tailEdge, + }) + arrowDetach.tailShapeId = null + arrowDetach.tailEdge = null + arrowDetach.tailPortIndex = 0 + ed.tailShapeId = null + ed.tailEdge = null + ed.tailPortIndex = 0 + } + if (ed.headShapeId || ed.headEdge) { + if (ed.headShapeId && ed.headEdge) + bodyDetachPorts.push({ + shapeId: ed.headShapeId, + edge: ed.headEdge, + }) + arrowDetach.headShapeId = null + arrowDetach.headEdge = null + arrowDetach.headPortIndex = 0 + ed.headShapeId = null + ed.headEdge = null + ed.headPortIndex = 0 + } + } - newShapeData = Object.assign( - {}, - shape.elementData, - { - data: { + if (Object.keys(arrowDetach).length > 0) { + // One UPDATE_BULK: position + binding clear. + updateComponentBulkPropertiesInLocalStore( + ed.id, + { x: parseInt(shape.translation.x), y: parseInt(shape.translation.y), - }, + ...arrowDetach, + } + ) + } else { + oldShapeData = { ...shape.elementData } + + newShapeData = Object.assign( + {}, + shape.elementData, + { + data: { + x: parseInt(shape.translation.x), + y: parseInt(shape.translation.y), + }, + } + ) + + if ( + shape.elementData.componentType !== + 'groupobject' + ) { + updateToGlobalState( + newShapeData, + oldShapeData + ) } - ) - - if ( - shape.elementData.componentType !== - 'groupobject' - ) { - updateToGlobalState(newShapeData, oldShapeData) } + + // Save the connectors that followed this shape so + // their new tail/head positions survive a reload. + persistBoundArrows(shape) + + // Re-close the fans on any port this connector was + // just dragged off of. + bodyDetachPorts.forEach((p) => + restackPortConnectors(p.shapeId, p.edge) + ) } } } @@ -2248,6 +3198,12 @@ function addZUI( el.style.pointerEvents = '' }) + // Radar is a drag-time affordance only — clear the glow on any release + // (covers the endpoint-drag path; the arrow-draw case clears it too). + // The pending snap was already consumed by the case handlers above. + selectionController.hidePortGlow() + pendingPortConnection = null + shape = {} scenario = null @@ -2276,9 +3232,10 @@ function addZUI( } function mousewheel(e: WheelEvent) { - // Pan mode treats a plain wheel/scroll as zoom (no modifier needed); - // otherwise the wheel pans the surface and shift/meta zooms. - if (e.shiftKey === true || e.metaKey === true || isPanMode()) { + // Wheel/scroll zooms only with a modifier held — cmd (macOS), ctrl + // (Windows; also what trackpad pinch-zoom emits), or shift. A plain + // wheel/scroll always pans the surface, in pan mode and otherwise. + if (e.shiftKey === true || e.metaKey === true || e.ctrlKey === true) { let dy = ((e as WheelEvent & { wheelDeltaY?: number }).wheelDeltaY || -e.deltaY) / 1000 @@ -2339,7 +3296,19 @@ function addZUI( lastTouch.clientY ) - if (!handleHit) { + // A multi-element group uses the older objectSelector path (not + // selectionController), so handleHit is false for it. Without this, + // the clearSelector below hides the group's dashed box the instant + // the drag begins on mobile. Skip the clear when the finger lands on + // the group object so its selector stays visible through the drag. + const groupHit = ( + document.elementFromPoint( + lastTouch.clientX, + lastTouch.clientY + ) as Element | null + )?.closest('[data-label="groupobject_coord"]') + + if (!handleHit && !groupHit) { // Clear any previous selection before processing the new tap. // On desktop this happens via focus/blur, but synthetic mouse events // don't transfer browser focus on mobile, so we do it explicitly here @@ -2631,6 +3600,8 @@ function addZUI( selectionController.currentGroup || activeGroupRef.current || lastSelectedShape, + bringSelectionToFront: () => + selectionController.bringSelectionToFront(), } } @@ -2652,6 +3623,7 @@ const Canvas: React.FC = (props) => { geoObjectsEnabled, undoLastAction, redoLastAction, + recordBatchToHistoryLog, enableTextDrawMode, createTextAtSurface, } = useBoardContext() @@ -2685,6 +3657,10 @@ const Canvas: React.FC = (props) => { const [zuiInstance, setZuiInstance] = useState(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const [onGroup, setOnGroup] = useState(null) + // Right-click / two-finger context menu position (null = closed). + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>( + null + ) const [componentsToRender, setComponentsToRender] = useState< React.ComponentType[] >([]) @@ -2723,6 +3699,7 @@ const Canvas: React.FC = (props) => { zuiInstanceRef, boardId: props.boardId, addToLocalComponentStore, + recordBatchToHistoryLog, renderGroupRef, }) @@ -2842,6 +3819,130 @@ const Canvas: React.FC = (props) => { } }, []) + // Handle for an in-flight z-order reconcile poll so a new store change can + // cancel a stale one instead of stacking rAF loops. + const zOrderPollRef = useRef(null) + + // Deterministically re-sort the element groups inside two.scene.children by + // their store `position` (back→front). Element mounting is async (React.lazy + // + Suspense), so groups land in the scene in unpredictable order — this is + // the single source of truth that fixes the post-refresh z-order. Reads live + // state from refs (stale-closure rule); idempotent and safe to re-run. + const reconcileZOrder = useCallback(() => { + const two = twoJSInstance + const store = stateRefForComponentStore.current + if (!two?.scene || !store) return + const scene = two.scene + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children = scene.children as any[] + + // Skip while a transient group selection (marquee selector or the + // mounted groupobject) is active. Such a group is NOT reorderable + // (isReorderableElementChild excludes GROUP_COMPONENT) and its grouped + // originals are hidden (opacity 0) beneath it, so there is nothing + // useful to reorder. More importantly, the groupobject mounts its child + // elements asynchronously: sorting + two.update() mid-mount can detach + // the just-built group node (the scene.subtractions pitfall in + // CLAUDE.md), silently destroying the selection. The componentStore + // effect re-runs this poll once the group is dismissed and the + // originals re-render, so deterministic z-order is still restored then. + const hasActiveGroupSelection = children.some( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => + c?.elementData?.isGroupSelector === true || + c?.elementData?.componentType === GROUP_COMPONENT + ) + if (hasActiveGroupSelection) return + + // Build the desired final order: element groups sorted by their store + // position (back→front) dropped back into the index slots they already + // occupy, while non-element children (selection box, previews) keep + // their slots. A rank map gives a *total* order — returning 0 for + // mixed pairs would break sort's transitivity contract and could + // mis-order elements separated by an overlay. + const sortedEls = children + .filter((c) => isReorderableElementChild(c, store)) + .sort((a, b) => + compareByZOrder( + store[a.elementData.id], + store[b.elementData.id] + ) + ) + let e = 0 + const desired = children.map((c) => + isReorderableElementChild(c, store) ? sortedEls[e++] : c + ) + const rank = new Map() + desired.forEach((c, i) => rank.set(c, i)) + + // Collection.sort fires the 'order' event that flags the SVG renderer + // to physically reorder the nodes — a bare splice would NOT. + children.sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0)) + + // The just-sorted elements may have buried the selection overlay — lift + // it back on top so the active selection box stays visible. + zuiInstanceRef.current?.bringSelectionToFront?.() + + try { + two.update() + } catch (err) { + // A concurrent mount/cleanup could leave a stale subtraction queued; + // clear it so future updates don't keep retrying the broken op (see + // the scene.subtractions pitfall in CLAUDE.md). + console.warn('reconcileZOrder two.update failed', err) + scene.subtractions.length = 0 + scene._flagSubtractions = false + } + }, [twoJSInstance]) + + // Element groups appear in the scene over several frames as their lazy + // chunks resolve. Poll a few frames, reconciling each tick, until every + // expected element group is present (or a frame cap as a safety stop). + const startZOrderReconcilePoll = useCallback(() => { + if (zOrderPollRef.current !== null) { + cancelAnimationFrame(zOrderPollRef.current) + zOrderPollRef.current = null + } + const two = twoJSInstance + const store = stateRefForComponentStore.current + if (!two?.scene || !store) return + + // Expected = store records that are reorderable AND map to a known + // element module (others are skipped by handleSetComponentsToRender). + const expected = Object.values(store).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (r: any) => + r?.componentType !== GROUP_COMPONENT && + !!elementModules[ + `./components/elements/${r?.componentType}.tsx` + ] + ).length + + let frame = 0 + const MAX_FRAMES = 90 + const tick = (): void => { + zOrderPollRef.current = null + reconcileZOrder() + const present = (two.scene.children as unknown[]).filter((c) => + isReorderableElementChild(c, store) + ).length + frame += 1 + if (present < expected && frame < MAX_FRAMES) { + zOrderPollRef.current = requestAnimationFrame(tick) + } + } + zOrderPollRef.current = requestAnimationFrame(tick) + }, [twoJSInstance, reconcileZOrder]) + + useEffect( + () => () => { + if (zOrderPollRef.current !== null) { + cancelAnimationFrame(zOrderPollRef.current) + } + }, + [] + ) + useEffect(() => { stateRefForComponentStore.current = props.componentStore if (twoJSInstance !== null && zuiInstance !== null) { @@ -2858,6 +3959,9 @@ const Canvas: React.FC = (props) => { if (Object.values(props.componentStore).length > 0 && twoJSInstance) { handleSetComponentsToRender(Object.values(props.componentStore)) + // Re-assert deterministic z-order once the (async) element mounts + // settle — this is what fixes the post-refresh ordering bug. + startZOrderReconcilePoll() } }, [props.componentStore]) @@ -2904,6 +4008,16 @@ const Canvas: React.FC = (props) => { // on group select use effect hook useEffect(() => { if (onGroup) { + // Cancel any in-flight z-order reconcile poll started by an earlier + // store change. The groupobject mounts its children asynchronously; + // a reconcile tick that fires in the gap *before* the group node is + // in the scene (so reconcileZOrder's group guard can't see it yet) + // would sort + two.update() mid-mount and detach the just-built + // group (scene.subtractions pitfall), dropping the selection. + if (zOrderPollRef.current !== null) { + cancelAnimationFrame(zOrderPollRef.current) + zOrderPollRef.current = null + } let e = onGroup let x1Coord = e.left let x2Coord = e.right @@ -2919,8 +4033,15 @@ const Canvas: React.FC = (props) => { const newChildren: any[] = [] const selectedComponentArr: string[] = [] + // Iterate in global z-order (position asc = back→front) so the + // group's internal child order mirrors the canvas stacking. The + // group adds children in array order (groupobject.tsx) where + // index 0 is the backmost, so feeding them sorted keeps grouped + // elements visually consistent with their ungrouped positions. const allComponentCoords = stateRefForComponentStore.current - ? Object.values(stateRefForComponentStore.current) + ? Object.values(stateRefForComponentStore.current).sort( + compareByZOrder + ) : [] allComponentCoords.forEach((item) => { if ( @@ -3008,12 +4129,14 @@ const Canvas: React.FC = (props) => { newGroup.children = newChildren - twoJSInstance.scene.children.forEach((child: any) => { - if (selectedComponentArr.includes(child?.elementData?.id)) { - child.opacity = 0 - twoJSInstance.update() - } - }) + // Defer hiding the originals to the group's own assembly so the + // swap is atomic — the group hides exactly these ids in the SAME + // two.update() that paints its member copies, so there is never a + // blank frame between "originals hidden" and "group copies drawn" + // (the residual group-select flicker). Only the group-SELECT path + // sets this; paste leaves it unset (its clones have no on-canvas + // originals to hide). + newGroup.membersToHide = [...selectedComponentArr] handleSetComponentsToRender([newGroup]) } @@ -3067,6 +4190,175 @@ const Canvas: React.FC = (props) => { } }, [undoLastAction, redoLastAction, enableTextDrawMode]) + // Export the active selection (marquee group or single element) as a + // standalone .svg. getSelectedGroup() unifies both selection mechanisms. + const exportActiveSelection = useCallback(async () => { + const group = zuiInstanceRef.current?.getSelectedGroup?.() + if (!group) return + try { + await exportSelectionAsSvg(group) + } catch (err) { + console.warn('Export selection as SVG failed', err) + } + }, []) + + // Change the z-order of the currently-selected element. We move by *index* + // in the deterministic sorted order, then renumber every row to a dense, + // distinct position (0..n-1). The old approach swapped position *values*, + // which silently no-ops whenever neighbours tie — and most legacy rows share + // position 0 (position is only assigned to newly-created elements), so once a + // shape stepped into the 0-block it could never come back. Renumbering + // self-heals that degeneracy; only rows whose position actually changes are + // written, so steady-state single-step moves touch just the couple that + // shifted. The whole renumber is recorded as one BATCH = one undo step. + const reorderSelected = useCallback( + (op: 'front' | 'forward' | 'backward' | 'back') => { + const store = stateRefForComponentStore.current + if (!store) return + const id = + zuiInstanceRef.current?.getSelectedGroup?.()?.elementData?.id + if (!id || !store[id]) return + + const sorted = Object.values(store) + .filter((r) => r.componentType !== GROUP_COMPONENT) + .sort(compareByZOrder) + const n = sorted.length + if (n === 0) return + const idx = sorted.findIndex((r) => r.id === id) + if (idx === -1) return + + // Target slot for the selected element in the final back→front order. + const target = + op === 'front' + ? n - 1 + : op === 'back' + ? 0 + : op === 'forward' + ? idx + 1 + : idx - 1 // backward + if (target < 0 || target > n - 1 || target === idx) return // at edge + + // Rebuild the order with the selected element moved to `target`. + const newOrder = sorted.slice() + const [moved] = newOrder.splice(idx, 1) + if (!moved) return + newOrder.splice(target, 0, moved) + + // Assign dense distinct positions; write + record only what changed. + const batch: HistoryEntry[] = [] + newOrder.forEach((r, i) => { + const prev = Number.isFinite(r.position) + ? (r.position as number) + : 0 + if (prev === i) return + updateComponentBulkPropertiesInLocalStore( + r.id, + { position: i }, + true + ) + batch.push({ + action: 'UPDATE_BULK', + id: r.id, + prevProps: { position: prev }, + bulkObj: { position: i }, + }) + }) + if (batch.length > 0) recordBatchToHistoryLog(batch) + + reconcileZOrder() + }, + [ + updateComponentBulkPropertiesInLocalStore, + recordBatchToHistoryLog, + reconcileZOrder, + ] + ) + + // Publish reorderSelected up to board.tsx so the properties toolbar can + // trigger it through BoardContext (see the reorderSelectedRef bridge). + useEffect(() => { + const ref = props.reorderSelectedRef + if (ref) ref.current = reorderSelected + return () => { + if (ref) ref.current = null + } + }, [props.reorderSelectedRef, reorderSelected]) + + // Right-click (mouse) and two-finger trackpad tap both fire the native + // 'contextmenu' event. Suppress the OS menu; if something is selected, open + // our menu at the cursor. Cmd/Ctrl+Shift+D triggers the same export. + useEffect(() => { + const root = document.getElementById('main-two-root') + if (!root) return + + const onContextMenu = (evt: MouseEvent) => { + evt.preventDefault() + const group = zuiInstanceRef.current?.getSelectedGroup?.() + if (group) { + setCtxMenu({ x: evt.clientX, y: evt.clientY }) + } else { + setCtxMenu(null) + } + } + + const onExportKeyDown = (evt: KeyboardEvent) => { + if ( + evt.key.toLowerCase() !== 'd' || + !(evt.ctrlKey || evt.metaKey) || + !evt.shiftKey + ) + return + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + evt.preventDefault() + void exportActiveSelection() + } + + // Reorder shortcuts: + // [ / ] → backward / forward (one step) + // ⌘[ / ⌘] → to back / to front + // We deliberately avoid ⌘⇧[/⌘⇧] here: on macOS Chrome those are the + // reserved "switch tab" accelerators (native app-menu key equivalents) + // and preventDefault() can't cancel them — the page never wins. Detect + // brackets via code OR key so non-US layouts resolve too. Only hijack + // the key when a shape is actually selected, so bare [ /] stay inert and + // ⌘[ /⌘] keep their browser history-nav behaviour on an empty selection. + const onReorderKeyDown = (evt: KeyboardEvent) => { + if (evt.shiftKey || evt.altKey) return + const isRight = + evt.code === 'BracketRight' || + evt.key === ']' || + evt.key === '}' + const isLeft = + evt.code === 'BracketLeft' || evt.key === '[' || evt.key === '{' + if (!isRight && !isLeft) return + const el = document.activeElement as HTMLElement | null + const tag = el?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || el?.isContentEditable) + return + if (!zuiInstanceRef.current?.getSelectedGroup?.()) return + evt.preventDefault() + const withCmd = evt.metaKey || evt.ctrlKey + const op: 'front' | 'forward' | 'backward' | 'back' = isRight + ? withCmd + ? 'front' + : 'forward' + : withCmd + ? 'back' + : 'backward' + reorderSelected(op) + } + + root.addEventListener('contextmenu', onContextMenu) + window.addEventListener('keydown', onExportKeyDown) + window.addEventListener('keydown', onReorderKeyDown) + return () => { + root.removeEventListener('contextmenu', onContextMenu) + window.removeEventListener('keydown', onExportKeyDown) + window.removeEventListener('keydown', onReorderKeyDown) + } + }, [exportActiveSelection, reorderSelected]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const setOnGroupHandler = (obj: any) => { setOnGroup(obj) @@ -3229,6 +4521,21 @@ const Canvas: React.FC = (props) => { ))} + {ctxMenu && ( + setCtxMenu(null)} + onExportSvg={() => { + setCtxMenu(null) + void exportActiveSelection() + }} + onReorder={(op) => { + setCtxMenu(null) + reorderSelected(op) + }} + /> + )} ) } diff --git a/src/routes.ts b/src/routes.ts index cfe2acb..0dee2fd 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,4 +5,5 @@ export default { about: '/about', support: '/support', privacy: '/privacy', + embeddable: '/embeddable-whiteboard', } diff --git a/src/schema/queries/index.ts b/src/schema/queries/index.ts index 963657f..c495df4 100644 --- a/src/schema/queries/index.ts +++ b/src/schema/queries/index.ts @@ -49,6 +49,7 @@ export const GET_COMPONENTS_FOR_BOARD_QUERY: TypedDocumentNode< query getComponentsForBoard($boardId: uuid = "") { components: components_component( where: { boardId: { _eq: $boardId } } + order_by: { position: asc } ) { id componentType @@ -69,6 +70,7 @@ export const GET_COMPONENTS_FOR_BOARD_QUERY: TypedDocumentNode< linewidth strokeType textColor + position } } ` diff --git a/src/types/board.ts b/src/types/board.ts index 4b0715a..4974741 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -46,6 +46,24 @@ export interface ComponentRecord { isDummy: boolean | null updatedBy: string | null createdAt: number | null + /** + * Z-order key (back→front). Lower draws first (behind), higher draws on + * top — matching Two.js `scene.children` where index 0 is the back. New + * elements get `max(position)+1` (assigned in addToLocalComponentStore). + * Optional/nullable: legacy DB rows and directly-seeded records (e.g. the + * welcome sketch) may omit it; the z-order reconcile treats absent as 0. + */ + position?: number | null + /** + * Connector binding (arrowLine only): the arrow's tail/head is pinned to a + * shape's edge port and re-anchors when that shape moves/resizes. Stores the + * bound shape's id and the edge (`n/e/s/w-resize`). Null/absent = free + * endpoint; cleared when the user manually drags that endpoint off. + */ + tailShapeId?: string | null + tailEdge?: string | null + headShapeId?: string | null + headEdge?: string | null } export type ComponentStore = Record @@ -205,8 +223,21 @@ export interface BoardContextValue { stateRefForComponentStore: MutableRefObject // Property application - applyProperty: (name: string, value: unknown) => void - applyGroupProperty: (name: string, value: unknown) => void + applyProperty: ( + name: string, + value: unknown, + opts?: { preview?: boolean } + ) => void + applyGroupProperty: ( + name: string, + value: unknown, + opts?: { preview?: boolean } + ) => void + + // Z-order of the currently-selected element. Bridged up from newCanvas via + // a ref (the implementation lives there alongside reconcileZOrder); a no-op + // until Canvas has mounted and populated it. + reorderSelected: (op: 'front' | 'forward' | 'backward' | 'back') => void // Element defaults (read sites: ElementPropertiesToolbar, primary sidebar, factories) defaultFill: string diff --git a/src/utils/applyGroupProperty.ts b/src/utils/applyGroupProperty.ts index 6269728..797eefe 100644 --- a/src/utils/applyGroupProperty.ts +++ b/src/utils/applyGroupProperty.ts @@ -62,14 +62,10 @@ export interface ApplyGroupPropertyDeps { } const ACCEPTS: Record> = { - fill: new Set([ - 'rectangle', - 'circle', - 'diamond', - 'frame', - 'newText', - 'geoText', - ]), + // Standalone text (newText/geoText) has no background-fill concept — its + // color is `textColor`. Excluded so a group fill leaves text untouched and + // never stamps a spurious `fill` onto a text row. + fill: new Set(['rectangle', 'circle', 'diamond', 'frame']), stroke: new Set([ 'rectangle', 'circle', @@ -205,7 +201,11 @@ export function createApplyGroupProperty(deps: ApplyGroupPropertyDeps) { return function applyGroupProperty( propertyKey: GroupPropertyKey | string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any + value: any, + // `preview` applies the change to the live Two.js scene only, skipping + // the store/history writes — used for continuous slider drags. The final + // value commits normally (no preview) on release. + opts?: { preview?: boolean } ): void { const { selectedGroup, @@ -323,11 +323,20 @@ export function createApplyGroupProperty(deps: ApplyGroupPropertyDeps) { if (propertyKey === 'opacity') { const sceneLeaf = sceneEl?.children?.[0] if (sceneLeaf) sceneLeaf.opacity = value + // Dim embedded text alongside the shape leaf (rect/diamond/ + // circle-with-text keep text in a separate text-layer node). + findTextNodesInside(sceneEl).forEach((t) => (t.opacity = value)) if (coreObj) { coreObj.opacity = 1 const coreLeaf = coreObj?.children?.[0] if (coreLeaf) coreLeaf.opacity = value + findTextNodesInside(coreObj).forEach( + (t) => (t.opacity = value) + ) } + // Live drag preview: scene only, defer the store/history write + // to the commit on release. + if (opts?.preview) return const existingMeta = sceneEl?.elementData?.metadata const safeMeta = existingMeta && !Array.isArray(existingMeta) diff --git a/src/utils/applyProperty.ts b/src/utils/applyProperty.ts index 3772075..49d8c2f 100644 --- a/src/utils/applyProperty.ts +++ b/src/utils/applyProperty.ts @@ -73,7 +73,12 @@ export function createApplyProperty(deps: ApplyPropertyDeps) { return function applyProperty( propertyKey: PropertyKey | string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any + value: any, + // `preview` applies the change to the live Two.js scene only, skipping + // the store/history/default writes — used for continuous slider drags so + // the element updates in real time without spamming the undo stack. The + // final value is committed normally (no preview) on release. + opts?: { preview?: boolean } ): void { const { selectedComponent, @@ -88,23 +93,26 @@ export function createApplyProperty(deps: ApplyPropertyDeps) { setDefaultStrokeColor, setDefaultLinewidth, setDefaultStrokeType, - setDefaultOpacity, setDefaultTextColor, setDefaultTextSize, setDefaultTextFontFamily, } = deps - // 1. Update the matching default. - if (propertyKey === 'fill') setDefaultFill(value) - else if (propertyKey === 'stroke') setDefaultStrokeColor(value) - else if (propertyKey === 'linewidth') setDefaultLinewidth(value) - else if (propertyKey === 'strokeType') - setDefaultStrokeType(value === 'solid' ? null : value) - else if (propertyKey === 'opacity') setDefaultOpacity(value) - else if (propertyKey === 'textColor') setDefaultTextColor(value) - else if (propertyKey === 'textSize') setDefaultTextSize(value) - else if (propertyKey === 'textFontFamily') - setDefaultTextFontFamily(value) + // 1. Update the matching default. Opacity is deliberately excluded — it + // is a per-element property only and must never persist as a default, + // otherwise drawing a new shape after dimming one (e.g. to 0%) would + // produce an invisible shape. Skipped entirely in preview mode. + if (!opts?.preview) { + if (propertyKey === 'fill') setDefaultFill(value) + else if (propertyKey === 'stroke') setDefaultStrokeColor(value) + else if (propertyKey === 'linewidth') setDefaultLinewidth(value) + else if (propertyKey === 'strokeType') + setDefaultStrokeType(value === 'solid' ? null : value) + else if (propertyKey === 'textColor') setDefaultTextColor(value) + else if (propertyKey === 'textSize') setDefaultTextSize(value) + else if (propertyKey === 'textFontFamily') + setDefaultTextFontFamily(value) + } // 2. If nothing is selected, we're done. if (!selectedComponent) return @@ -232,13 +240,32 @@ export function createApplyProperty(deps: ApplyPropertyDeps) { strokeType: dbValue, }) } else if (propertyKey === 'opacity') { - if (shapeData) shapeData.opacity = value - const existingMeta = elementData?.metadata ?? {} - const updatedMeta = { ...existingMeta, opacity: value } - if (elementData) elementData.metadata = updatedMeta - updateComponentBulkPropertiesInLocalStore(id, { - metadata: updatedMeta, - }) + // Apply opacity at the GROUP level, not the shape leaf. The leaf + // path is double-referenced in group.children (the *-with-text + // components unshift the factory's already-added shape), which + // leaves leaf-level opacity flags unprocessed on render — so a leaf + // write only appears after the next full repaint (e.g. on deselect). + // The group's own opacity always repaints, and it uniformly dims the + // shape plus any embedded text-layer nodes in one shot. + const groupObj = selectedComponent?.group?.data + if (groupObj) groupObj.opacity = value + // Neutralize any leaf/text opacity so it doesn't compound with the + // group's (e.g. shapes mounted before this change carried leaf-level + // opacity). + if (shapeData) shapeData.opacity = 1 + getShapeTextNodes(selectedComponent?.group?.data).forEach( + (n) => (n.opacity = 1) + ) + // Preview = live drag: mutate the scene only, defer the + // store/history write to the commit on release. + if (!opts?.preview) { + const existingMeta = elementData?.metadata ?? {} + const updatedMeta = { ...existingMeta, opacity: value } + if (elementData) elementData.metadata = updatedMeta + updateComponentBulkPropertiesInLocalStore(id, { + metadata: updatedMeta, + }) + } } twoJSInstance?.update() diff --git a/src/utils/canvasUtils.ts b/src/utils/canvasUtils.ts index 5c5d6cf..b24b95e 100644 --- a/src/utils/canvasUtils.ts +++ b/src/utils/canvasUtils.ts @@ -1,4 +1,7 @@ -import { SHAPE_DEFAULT_STROKE } from '../constants/misc' +import { + SHAPE_DEFAULT_STROKE, + DEFAULT_TEXT_FONT_FAMILY, +} from '../constants/misc' import { generateUUID } from './misc' import { lineHeightFor, measureTextWidth, type FontSpec } from './textLayout' import { reflowTextForShape } from './shapeTextFit' @@ -276,11 +279,125 @@ export function getShapeTextNodes(group: ShapeLike): ShapeLike[] { const layer = findShapeTextLayer(group) const source = layer ? layer.children : group?.children if (!source) return [] - return source.filter( + // `source` is a Two.js `Children` collection (a custom Array subclass with + // no Symbol.species). Calling `.filter` on it directly routes through + // `ArraySpeciesCreate(new Children(0))`, whose constructor mishandles the + // numeric length and seeds the result with a spurious `0`. When there are + // no text nodes that `0` survives, so the filter returns `[0]` instead of + // `[]` — and any caller dereferencing the result (`n.opacity`, `n.fill`) + // throws `Cannot create property … on number '0'`. Copy to a plain array + // first so the filter is well-behaved and returns `[]` for text-less shapes. + return Array.from(source as ArrayLike).filter( (c: ShapeLike) => typeof c?.value === 'string' ) } +/** + * Keep a transparent, full-block hit-area rectangle inside a STANDALONE text + * group, sized to the rendered multiline block (anchored left/middle at the + * group origin, matching the text layout). + * + * Why this exists: an SVG `` only catches pointer events on the glyphs + * themselves. For multiline text rendered as stacked `` nodes, the blank + * gaps between lines (and the padding around them) belong to no element, so a + * click there misses the group `` entirely and `resolveShapeFromPath` reads + * it as "empty canvas" — the text can't be selected as a whole. A + * transparent-but-painted (`rgba(0,0,0,0)`) rect spanning the block restores a + * solid hit target across the whole block. (This is what the old per-element + * `ObjectSelector.area` path used to provide before selection moved to the + * generic SelectionController.) + * + * Idempotent: creates the rect on first call (tagged via `_isTextHitArea`), + * resizes it on later calls. Added AFTER line 1 so `group.children[0]` stays + * the text node the SelectionController attaches to, and excluded from + * `getShapeTextNodes` (no string `value`). + */ +export function syncTextHitRect(two: TwoLike, group: ShapeLike): void { + const nodes = getShapeTextNodes(group) + if (!nodes.length) return + const size = nodes[0]?.size || 36 + const lineH = lineHeightFor(size) + let maxW = 20 + nodes.forEach((nd) => { + const w = measureTextWidth(nd?.value || '', { + family: nd?.family || DEFAULT_TEXT_FONT_FAMILY, + size: nd?.size || size, + weight: nd?.weight, + }) + maxW = Math.max(maxW, w) + }) + const blockH = Math.max(nodes.length * lineH, size) + + let rect = Array.from(group.children as ArrayLike).find( + (c: ShapeLike) => c?._isTextHitArea + ) + if (!rect) { + rect = two.makeRectangle(0, 0, maxW, blockH) + rect.fill = 'rgba(0,0,0,0)' + rect.noStroke() + rect._isTextHitArea = true + group.add(rect) + } + rect.width = maxW + rect.height = blockH + // Text is left-aligned at the group origin (extends right) and vertically + // centered on it, so center the rect at (width/2, 0). + rect.translation.set(maxW / 2, 0) +} + +/** + * Lay out STANDALONE text (the `newText` kind) as a vertical stack of one + * Two.Text per hard-newline line, centered on the group origin. An SVG + * collapses `\n`, so multiline standalone text must be rendered as stacked + * nodes — newText's component does this internally, but the same layout is + * needed whenever the text is re-materialised outside that component (e.g. as a + * cloned member of a group selection). Reuses any existing line nodes (line 1 is + * the factory's text node), adds nodes for new lines, removes surplus ones. + * + * Keep in sync with newText.tsx's `syncMultilineLayout`. + */ +export function layoutStandaloneText( + two: TwoLike, + group: ShapeLike, + content: string, + size: number +): void { + const nodes = getShapeTextNodes(group) + const first = nodes[0] + if (!first) return + const lines = (content || '').split('\n') + const n = lines.length + const lineH = lineHeightFor(size) + + first.value = lines[0] ?? '' + first.size = size + first.leading = size + first.translation.set(0, (0 - (n - 1) / 2) * lineH) + + const extra = nodes.slice(1) + for (let i = 1; i < n; i++) { + let node = extra[i - 1] + if (!node) { + node = two.makeText(lines[i] ?? '', 0, 0) + group.add(node) + } + node.value = lines[i] ?? '' + node.fill = first.fill + node.size = size + node.leading = size + node.family = first.family + node.alignment = first.alignment + node.baseline = first.baseline + node.opacity = first.opacity + node.translation.set(0, (i - (n - 1) / 2) * lineH) + } + + if (extra.length > n - 1) { + const surplus = extra.slice(n - 1) + if (surplus.length) group.remove(surplus) + } +} + /** * Render `lines` as a vertical stack of Two.Text nodes inside `group`'s text * layer, creating the layer on first use. Existing line nodes are reused @@ -360,7 +477,8 @@ export function shapeTextStyleFromMeta(meta: ShapeLike): { style: ShapeTextStyle font: FontSpec } { - const family = meta?.textFontFamily || meta?.textFamily || 'Caveat' + const family = + meta?.textFontFamily || meta?.textFamily || DEFAULT_TEXT_FONT_FAMILY const size = meta?.textFontSize || 24 const weight = meta?.textWeight || 'normal' return { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index da16106..7969917 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -88,6 +88,14 @@ export const allColorShades: string[] = [ '#C0B6F2', '#EAE6FF', + // Brown — 6 shades, light → dark. + '#EAD9C5', + '#D2B089', + '#B5824E', + '#8C5A2B', + '#5E3A1A', + '#3D2410', + '#172B4D', '#253858', '#42526E', @@ -109,15 +117,15 @@ export const essentialShades: string[] = [ '#0065FF', ] -// Fill picker only — transparent ("no fill") first, replacing the green that -// the shared essentialShades keeps for stroke/text. +// Fill picker only — transparent ("no fill") and white kept, followed by light +// pastel shades suited to fills (vs. the saturated stroke/text essentialShades). export const fillEssentialShades: string[] = [ TRANSPARENT_FILL, '#FFFFFF', - '#000000', - '#FF5630', - '#FFAB00', - '#0065FF', + '#FFBDAD', + '#FFF0B3', + '#ABF5D1', + '#B3D4FF', ] export interface DrawerElement { @@ -133,7 +141,6 @@ export interface PrimaryElement { hasDrawer: boolean noAction: boolean drawerData: DrawerElement[] - mobileOnly?: boolean } export interface PrimarySection { @@ -160,7 +167,6 @@ export const staticPrimaryElementData: PrimarySection[] = [ hasDrawer: false, noAction: true, drawerData: [], - mobileOnly: true, }, { elementName: 'shapes', diff --git a/src/utils/exportSelectionAsSvg.ts b/src/utils/exportSelectionAsSvg.ts new file mode 100644 index 0000000..5f8e49e --- /dev/null +++ b/src/utils/exportSelectionAsSvg.ts @@ -0,0 +1,103 @@ +// Export the currently-selected element(s) as a standalone, tightly-cropped +// .svg file with a transparent background and no watermark. +// +// Two.js runs the SVG renderer, so the selected group already has a live +// rendered node at group._renderer.elem. We clone that , drop it inside a +// fresh , and crop to its content via getBBox() on a transform-free wrapper +// — this sidesteps the ZUI zoom/pan transform entirely: the clone keeps its own +// (unzoomed, scene-space) transform, getBBox reports its bounds in that same +// space, and using those bounds as the viewBox yields a 1:1 portable asset. + +import { SVG_NS, embedFonts } from './svgExportShared' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GroupLike = any + +/** + * Export a Two.js group (or single-element group) as a downloaded .svg file. + * Throws if the group has no rendered SVG node or has zero size. + */ +export async function exportSelectionAsSvg(group: GroupLike): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gEl: SVGGElement | undefined = (group as any)?._renderer?.elem + if (!gEl) throw new Error('No rendered SVG node found for the selection') + + const clone = gEl.cloneNode(true) as SVGGElement + + // A marquee group's also contains the transparent marquee-sized rect and + // the visible selection border/handles (added as children of the group). For + // a single-element selection the group's is already clean (its selection + // UI lives separately in the scene), so we only prune marquee groups. Keep + // only the direct children that map to real content elements (those carrying + // elementData.id); everything else is selection chrome. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((group as any)?.elementData?.isGroupSelector) { + const keepIds = new Set( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((group as any).children || []) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((c: any) => c?.elementData?.id) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((c: any) => c?._renderer?.elem?.id) + .filter((id: unknown): id is string => typeof id === 'string') + ) + Array.from(clone.children).forEach((node) => { + if (!keepIds.has(node.id)) node.remove() + }) + } + + const svg = document.createElementNS(SVG_NS, 'svg') + svg.setAttribute('xmlns', SVG_NS) + // Transform-free wrapper so getBBox reports bounds in the svg's user space, + // accounting for the clone's own transform via descendant geometry. + const wrapper = document.createElementNS(SVG_NS, 'g') + wrapper.appendChild(clone) + svg.appendChild(wrapper) + + // getBBox requires the node be laid out (in the DOM, not display:none). + svg.setAttribute( + 'style', + 'position:fixed;left:-99999px;top:-99999px;opacity:0;pointer-events:none' + ) + document.body.appendChild(svg) + + try { + const bb = wrapper.getBBox() + if (bb.width === 0 || bb.height === 0) { + throw new Error('Selection has zero size; nothing to export') + } + + svg.setAttribute('width', String(bb.width)) + svg.setAttribute('height', String(bb.height)) + svg.setAttribute( + 'viewBox', + `${bb.x} ${bb.y} ${bb.width} ${bb.height}` + ) + + // Font fetch is async; keep the element hidden (style attr) until the + // synchronous serialize below so it never flashes on-screen. + await embedFonts(svg) + + // Strip the off-screen hiding style only from the serialized output. + svg.removeAttribute('style') + const svgString = new XMLSerializer().serializeToString(svg) + downloadSvg(svgString) + } finally { + if (svg.parentNode) svg.parentNode.removeChild(svg) + } +} + +/** Trigger a browser download of the SVG markup as a .svg file. */ +function downloadSvg(svgString: string): void { + const blob = new Blob([svgString], { + type: 'image/svg+xml;charset=utf-8', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `craftbase-selection-${Date.now()}.svg` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/src/utils/exportViewport.ts b/src/utils/exportViewport.ts index 9de346c..e833ebb 100644 --- a/src/utils/exportViewport.ts +++ b/src/utils/exportViewport.ts @@ -9,14 +9,13 @@ // stamp a screen-space watermark, then draw the SVG onto a and // download it as PNG. -const SVG_NS = 'http://www.w3.org/2000/svg' +import { SVG_NS, embedFonts } from './svgExportShared' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' + const CANVAS_BG = '#f5f0e8' // --color-canvas (App.css) const DOT_COLOR = '#c4b89a' // radial-gradient dot color (App.css) const DOT_TILE = 24 // background-size: 24px 24px (App.css) const WATERMARK_TEXT = 'Made with craftbase.org' -// Fonts used for canvas text. --font-sketch: 'Caveat' (App.css). Embedded so -// rasterized text matches the screen instead of falling back to a system font. -const FONT_FAMILIES = ['Caveat'] const MAX_DPR = 2 // cap device-pixel scaling to bound output file size /** @@ -100,7 +99,7 @@ function appendWatermark( text.setAttribute('x', String(width - 16)) text.setAttribute('y', String(height - 14)) text.setAttribute('text-anchor', 'end') - text.setAttribute('font-family', 'Caveat') + text.setAttribute('font-family', DEFAULT_TEXT_FONT_FAMILY) text.setAttribute('font-size', '20') text.setAttribute('fill', '#8C7E6A') text.setAttribute('fill-opacity', '0.8') @@ -109,60 +108,6 @@ function appendWatermark( svg.appendChild(text) } -/** - * Inline the Google web font(s) as base64 inside a
    ` items become `•`/`◦`/`▪` per nesting depth; `
      ` items become +// `1.`, `2.`, … Nested lists are indented two spaces per level. Non-list block +// elements (`p`, `div`, headings, `br`) become line breaks so paragraphs keep +// their structure. Returns `null` when the HTML has no list at all, so callers +// can fall back to the browser's default plain-text paste. + +const UL_MARKERS = ['•', '◦', '▪'] +const BLOCK_TAGS = new Set([ + 'p', + 'div', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'section', + 'article', + 'header', + 'footer', + 'tr', +]) + +const INDENT = ' ' + +function inlineText(node: Node): string { + // Text of a node EXCLUDING any nested lists (those are emitted separately). + let out = '' + node.childNodes.forEach((child) => { + if (child.nodeType === Node.TEXT_NODE) { + out += child.textContent ?? '' + } else if (child.nodeType === Node.ELEMENT_NODE) { + const tag = (child as Element).tagName.toLowerCase() + if (tag === 'ul' || tag === 'ol') return + if (tag === 'br') { + out += '\n' + return + } + out += inlineText(child) + } + }) + return out.replace(/[ \t\f\v]+/g, ' ').trim() +} + +function emitList(listEl: Element, depth: number, lines: string[]): void { + const ordered = listEl.tagName.toLowerCase() === 'ol' + let index = 1 + listEl.childNodes.forEach((li) => { + if ( + li.nodeType !== Node.ELEMENT_NODE || + (li as Element).tagName.toLowerCase() !== 'li' + ) { + return + } + const marker = ordered + ? `${index}.` + : (UL_MARKERS[depth % UL_MARKERS.length] ?? '•') + const text = inlineText(li) + lines.push(`${INDENT.repeat(depth)}${marker} ${text}`.trimEnd()) + index += 1 + // Emit nested lists below the item; the item's own inline text is + // already in the line above (inlineText excludes nested lists). + li.childNodes.forEach((sub) => { + if (sub.nodeType !== Node.ELEMENT_NODE) return + const subTag = (sub as Element).tagName.toLowerCase() + if (subTag === 'ul' || subTag === 'ol') { + emitList(sub as Element, depth + 1, lines) + } + }) + }) +} + +function walk(node: Node, depth: number, lines: string[]): void { + node.childNodes.forEach((child) => { + if (child.nodeType === Node.TEXT_NODE) { + const t = (child.textContent ?? '').replace(/\s+/g, ' ').trim() + if (t) lines.push(t) + return + } + if (child.nodeType !== Node.ELEMENT_NODE) return + + const el = child as Element + const tag = el.tagName.toLowerCase() + + if (tag === 'ul' || tag === 'ol') { + emitList(el, depth, lines) + return + } + + if (tag === 'br') { + lines.push('') + return + } + + if (BLOCK_TAGS.has(tag)) { + const before = lines.length + walk(el, depth, lines) + // Keep paragraph separation between consecutive block siblings. + if (lines.length > before) lines.push('') + return + } + + // Inline wrapper (span, a, strong, …) — descend without a new line. + walk(el, depth, lines) + }) +} + +export function htmlToBulletText(html: string): string | null { + if (!html) return null + const doc = new DOMParser().parseFromString(html, 'text/html') + if (!doc.querySelector('li')) return null + + const lines: string[] = [] + walk(doc.body, 0, lines) + + return lines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/^\n+|\n+$/g, '') +} diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 2a63d11..01e1159 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,5 +1,18 @@ import type { RandomUsername } from '../types/board' +// True on macOS/iOS, where ⌘ (metaKey) is the primary shortcut modifier; Ctrl +// elsewhere. Prefer the modern userAgentData.platform, fall back to the legacy +// navigator.platform. Computed once at module load — the OS doesn't change. +export const isMac: boolean = + typeof navigator !== 'undefined' && + /mac|iphone|ipad|ipod/i.test( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).userAgentData?.platform || navigator.platform || '' + ) + +/** Display label for the primary shortcut modifier: '⌘' on mac, 'Ctrl' else. */ +export const PRIMARY_MOD_LABEL: string = isMac ? '⌘' : 'Ctrl' + export function strokeTypeToDashes(strokeType: string | null | undefined): number[] { if (strokeType === 'dashed') return [8] if (strokeType === 'dotted') return [4] diff --git a/src/utils/shapePorts.ts b/src/utils/shapePorts.ts new file mode 100644 index 0000000..8009dca --- /dev/null +++ b/src/utils/shapePorts.ts @@ -0,0 +1,147 @@ +// Geometry for connection ports. Given a shape group and an edge, returns the +// surface-space anchor at that edge's midpoint — the point a connector arrow's +// tail (or head) pins to, and re-anchors to whenever the shape moves/resizes. + +// Two.js scene groups carry codebase-specific bookkeeping outside the published +// types; stay loose here (matches selectionController/newCanvas convention). +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GroupLike = any + +// Edge names mirror the resize-handle naming used by the selection controller. +export type PortEdge = 'n-resize' | 'e-resize' | 's-resize' | 'w-resize' + +// `gap` (surface units) pushes the anchor outward past the edge so the tail +// lands on the floated selection port instead of flush against the edge. +export function getShapePortPoint( + group: GroupLike, + edge: string, + gap = 0 +): { x: number; y: number } { + const shape = group?.children?.[0] + const w = shape?.width ?? group?.elementData?.width ?? 0 + const h = shape?.height ?? group?.elementData?.height ?? 0 + const cx = group?.translation?.x ?? 0 + const cy = group?.translation?.y ?? 0 + + let ox = 0 + let oy = 0 + switch (edge) { + case 'n-resize': + oy = -(h / 2 + gap) + break + case 's-resize': + oy = h / 2 + gap + break + case 'e-resize': + ox = w / 2 + gap + break + case 'w-resize': + ox = -(w / 2 + gap) + break + default: + break + } + + // Honour shape rotation so the port tracks a rotated edge midpoint. + const rot = group?.rotation || 0 + if (rot) { + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const rx = ox * cos - oy * sin + const ry = ox * sin + oy * cos + ox = rx + oy = ry + } + + return { x: cx + ox, y: cy + oy } +} + +// Surface-px gap between successive connector tails stacked on the same port. +// When several connectors leave one port they fan out along the edge by this +// step instead of all pinning to the exact port point (which bunches them up +// and hides which tail belongs to which arrow). +export const PORT_TAIL_STACK_GAP = 5 + +// Fan a connector endpoint (tail OR head) outward along its port edge so it +// doesn't sit on top of the other connectors docked at the same port. `index` +// is this connector's slot among them (0 = the bare port point, no offset). +// `far` is the connector's OTHER endpoint — the fan direction follows its +// quadrant relative to the port: left/right (`e`/`w`) ports spread vertically +// toward the far point's y-side; top/bottom (`n`/`s`) ports spread horizontally +// toward its x-side. Mirrors the green/blue candidate dots sketched above/below +// a right-edge port. +export function getStackedPortPoint( + edge: string, + port: { x: number; y: number }, + far: { x: number; y: number }, + index: number, + gap = PORT_TAIL_STACK_GAP +): { x: number; y: number } { + if (index <= 0) return { x: port.x, y: port.y } + const offset = index * gap + if (edge === 'e-resize' || edge === 'w-resize') { + const dir = far.y >= port.y ? 1 : -1 + return { x: port.x, y: port.y + dir * offset } + } + if (edge === 'n-resize' || edge === 's-resize') { + const dir = far.x >= port.x ? 1 : -1 + return { x: port.x + dir * offset, y: port.y } + } + return { x: port.x, y: port.y } +} + +// The four edges a connector can dock to, in the same order the selection +// controller floats its port dots. +export const PORT_EDGES: PortEdge[] = [ + 'n-resize', + 'e-resize', + 's-resize', + 'w-resize', +] + +export interface PortCandidate { + group: GroupLike + shapeId: string | undefined + edge: PortEdge + // Surface-space anchor of the port (already offset outward by `gap`). + point: { x: number; y: number } + // Surface-space distance from the query point to this port. + distance: number +} + +// Radar search used while a connector is being drawn: among the candidate +// `groups`, find the edge port closest to `point` that sits within `threshold` +// surface units. Only rectangles expose ports (mirrors the selection +// controller's `isRect` gate); `excludeShapeId` drops the connector's own +// source shape so it can't dock back onto itself. Returns null when no port is +// in range. +export function findNearestPort( + groups: GroupLike[], + point: { x: number; y: number }, + threshold: number, + gap = 0, + excludeShapeId?: string | null +): PortCandidate | null { + const thresholdSq = threshold * threshold + let best: PortCandidate | null = null + let bestSq = thresholdSq + + for (const group of groups) { + if (group?.elementData?.componentType !== 'rectangle') continue + const shapeId = group?.elementData?.id + if (excludeShapeId && shapeId === excludeShapeId) continue + + for (const edge of PORT_EDGES) { + const p = getShapePortPoint(group, edge, gap) + const dx = p.x - point.x + const dy = p.y - point.y + const d2 = dx * dx + dy * dy + if (d2 > bestSq) continue + bestSq = d2 + best = { group, shapeId, edge, point: p, distance: 0 } + } + } + + if (best) best.distance = Math.sqrt(bestSq) + return best +} diff --git a/src/utils/svgExportShared.ts b/src/utils/svgExportShared.ts new file mode 100644 index 0000000..f565022 --- /dev/null +++ b/src/utils/svgExportShared.ts @@ -0,0 +1,67 @@ +// Shared helpers for the SVG-based export flows (viewport PNG export and +// selection SVG export). Two.js runs the SVG renderer, so both flows clone the +// live SVG and need to inline the canvas web font(s) — the SVG→ +// rasterizer and any standalone .svg consumer have no access to the document's +// loaded fonts, so without embedding, canvas text falls back to a system font. + +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' + +export const SVG_NS = 'http://www.w3.org/2000/svg' + +// Fonts used for canvas text, embedded so exported text matches the screen +// instead of falling back to a system font. Only the Regular 400 weight is +// requested (canvas text is always 400) — and the default font (Caveat Brush) +// ships a single weight, so requesting a range would 400 the whole CSS fetch. +const FONT_FAMILIES = [DEFAULT_TEXT_FONT_FAMILY] + +/** + * Inline the Google web font(s) as base64 inside a