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/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.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/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 1e1b9a0..6cdb3ca 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -4,8 +4,16 @@ import { getShapeTextNodes, renderShapeTextLayer, shapeTextStyleFromMeta, + syncTextHitRect, } from '../utils/canvasUtils' import { reflowTextForShape, minShapeWidthForText } from '../utils/shapeTextFit' +import { + 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 @@ -25,6 +33,40 @@ 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 = { @@ -41,14 +83,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, } @@ -57,6 +98,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 @@ -134,7 +176,16 @@ 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 @@ -161,6 +212,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 { @@ -549,16 +602,25 @@ 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.portHandles.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._halfW = hw @@ -639,7 +701,7 @@ export default class SelectionController { surface: { x: number; y: number }, targetGroup?: ShapeLike ): void { - if (!this.portGlow) return + if (!this.portGlow || !getConnectorsEnabled()) return const scale = this.zui.scale || 1 this.portGlow.position.set(surface.x, surface.y) this.portGlow.scale = 1 / scale @@ -841,6 +903,9 @@ export default class SelectionController { // 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 } @@ -981,6 +1046,7 @@ export default class SelectionController { y: this.currentGroup.translation.y, }, initialRotation: this.currentGroup.rotation || 0, + initialFontSize: this.currentShape?.size ?? 36, } this._attachPointerStream() return true @@ -1081,6 +1147,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, @@ -1264,6 +1336,55 @@ export default class SelectionController { 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 { if (!this.interaction || this.interaction.mode !== 'rotate') return const { center, startSurface, initialRotation } = this.interaction @@ -1298,12 +1419,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/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/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index ea8abdf..b8488f3 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -9,6 +9,7 @@ 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 @@ -63,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 @@ -70,157 +75,182 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { return element && two.scene.children.includes(element) } + // 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')) + 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 scene = two.scene as any + scene.subtractions.length = 0 + scene._flagSubtractions = false + } + } + + // 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 + }) + } + + // 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 + } + + 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 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 } + } + 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] ?? + {}) 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, + }) + }) + + 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() + } + function onBlurHandler(e: FocusEvent): void { elementOnBlurHandler(e, selectorInstance, two) window.dispatchEvent(new CustomEvent('groupBlurred')) if (!isDeletingRef.current) { - const userId = localStorage.getItem('userId') + // 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 childrenIdsOfTheGroup = props.children.map( (item: ShapeLike) => item.id ) - let foundOriginalCount = 0 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const blurBatchEntries: any[] = [] - - 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 - two.scene.children.forEach((element: ShapeLike) => { if (!element.elementData) return if (childrenIdsOfTheGroup.includes(element.elementData.id)) { foundOriginalCount++ - // Restore the element's own (group-level) opacity rather than - // forcing 1 — it was hidden at 0 while the group was selected, - // and per-element opacity now lives on the group. metadata may - // be a pencil vertex array, so guard the `.opacity` read. + // 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 (!groupMoved) { - return - } - - let findRelativeDataForChild: ShapeLike = {} - props.children.forEach((item: ShapeLike) => { - if (item.id === element?.elementData?.id) { - findRelativeDataForChild = item - } - }) - 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 - ) - ) - }) - } - }) - } - - 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 c = current as ShapeLike - const positionChanged = - c.x !== updateObj.x || c.y !== updateObj.y - const metadataChanged = newMetadata !== c.metadata - - if (!positionChanged && !metadataChanged) { - two.update() - return - } - - 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() } }) - - if (blurBatchEntries.length > 0) { - recordBatchToHistoryLog(blurBatchEntries) - } + two.update() if (foundOriginalCount === 0 && props.children.length > 0) { const gx = parseInt(String(groupInstance.translation.x)) @@ -381,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 @@ -401,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) @@ -416,12 +472,26 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { } coreObject.elementData = item - group.add(coreObject) - orderGroupChildrenByZ(group) - 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 @@ -451,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 => { @@ -461,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 a7e3a91..8e95371 100644 --- a/src/components/elements/newText.tsx +++ b/src/components/elements/newText.tsx @@ -1,20 +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 { htmlToBulletText } from '../../utils/htmlToBulletText' import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' -import { useMediaQueryUtils } from '../../constants/exportHooks' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -23,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, @@ -39,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 || '' ) @@ -57,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() @@ -100,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 @@ -138,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 @@ -182,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') @@ -311,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) @@ -338,6 +251,12 @@ 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 || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = twoText.weight || 'normal' @@ -352,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') @@ -370,6 +286,29 @@ function NewText(props: ElementProps): ReactElement { 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 @@ -379,7 +318,7 @@ function NewText(props: ElementProps): ReactElement { const contentWidth = Math.max( measuredW + 40, - screenRect.width + 40, + minContentWidth + 40, 80 ) const contentHeight = Math.max( @@ -389,14 +328,29 @@ 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() + // Re-glue the editor to the text after any render (pan/zoom/content). + const repositionEditor = (): void => { + recomputeGeometry() + autoSizeAndCenter() + } - input.addEventListener('input', autoSizeAndCenter) + // 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 @@ -419,21 +373,9 @@ function NewText(props: ElementProps): ReactElement { const caret = start + converted.length input.selectionStart = caret input.selectionEnd = caret - autoSizeAndCenter() + onTextInput() }) - 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() - } - input.focus() input.addEventListener('keydown', (event: KeyboardEvent) => { @@ -454,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) } @@ -472,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, { @@ -514,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 }, []) @@ -623,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 @@ -647,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/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/constants/misc.ts b/src/constants/misc.ts index 5f12ea1..39a0a7a 100644 --- a/src/constants/misc.ts +++ b/src/constants/misc.ts @@ -199,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/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 eeed201..3996282 100644 --- a/src/hooks/useComponentHistory.ts +++ b/src/hooks/useComponentHistory.ts @@ -852,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 => { @@ -892,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/newCanvas.tsx b/src/newCanvas.tsx index 693a7e1..54b79b3 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -54,7 +54,9 @@ import { } 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, { @@ -70,6 +72,10 @@ import { PORT_TAIL_STACK_GAP, } from './utils/shapePorts' import { generateUUID } from './utils/misc' +import { + getConnectorsEnabled, + subscribeConnectorsEnabled, +} from './utils/featureFlags' import { velocityToLinewidth, smoothLinewidth, @@ -589,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. @@ -689,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) @@ -732,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 @@ -757,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 ) @@ -780,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 @@ -794,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() @@ -846,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) } @@ -1125,6 +1192,8 @@ function addZUI( 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, @@ -1311,6 +1380,10 @@ function addZUI( // 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 @@ -1376,6 +1449,7 @@ function addZUI( // 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 @@ -3158,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 @@ -3624,6 +3699,7 @@ const Canvas: React.FC = (props) => { zuiInstanceRef, boardId: props.boardId, addToLocalComponentStore, + recordBatchToHistoryLog, renderGroupRef, }) @@ -4053,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]) } 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/utils/canvasUtils.ts b/src/utils/canvasUtils.ts index cd9b904..b24b95e 100644 --- a/src/utils/canvasUtils.ts +++ b/src/utils/canvasUtils.ts @@ -292,6 +292,112 @@ export function getShapeTextNodes(group: ShapeLike): ShapeLike[] { ) } +/** + * 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 diff --git a/src/utils/constants.ts b/src/utils/constants.ts index da16106..d648e67 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -109,15 +109,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 +133,6 @@ export interface PrimaryElement { hasDrawer: boolean noAction: boolean drawerData: DrawerElement[] - mobileOnly?: boolean } export interface PrimarySection { @@ -160,7 +159,6 @@ export const staticPrimaryElementData: PrimarySection[] = [ hasDrawer: false, noAction: true, drawerData: [], - mobileOnly: true, }, { elementName: 'shapes', diff --git a/src/utils/featureFlags.ts b/src/utils/featureFlags.ts new file mode 100644 index 0000000..bdc7ee4 --- /dev/null +++ b/src/utils/featureFlags.ts @@ -0,0 +1,46 @@ +import { CONNECTORS_ENABLED_KEY } from '../constants/misc' + +// Live, user-toggleable feature flags backed by localStorage. +// +// Unlike build-time `BoardProps` flags (e.g. `geoObjectsEnabled`), these are +// edited at runtime from the Settings modal and must take effect on the +// already-running app. The value is cached in a module-level variable so the +// hot Two.js paths (selection-box render, hover hit-test, arrow radar — all +// living inside `addZUI`'s stale-closure DOM handlers) can read it cheaply and +// live via `getConnectorsEnabled()` without re-binding listeners or hitting +// localStorage every frame. React UI subscribes via `useConnectorsEnabled`. + +// Opt-in feature: default OFF. Users enable connectors from the Settings modal. +const DEFAULT_CONNECTORS_ENABLED = false + +type Listener = (enabled: boolean) => void + +let cached: boolean | null = null +const listeners = new Set() + +export function getConnectorsEnabled(): boolean { + if (cached === null) { + const stored = localStorage.getItem(CONNECTORS_ENABLED_KEY) + cached = stored === null ? DEFAULT_CONNECTORS_ENABLED : stored === 'true' + } + return cached +} + +export function setConnectorsEnabled(enabled: boolean): void { + cached = enabled + try { + localStorage.setItem(CONNECTORS_ENABLED_KEY, String(enabled)) + } catch { + // Persistence is best-effort; an in-memory toggle still works for the + // current session even if storage is full/blocked. + } + listeners.forEach((fn) => fn(enabled)) +} + +// Subscribe to live changes. Returns an unsubscribe fn. +export function subscribeConnectorsEnabled(fn: Listener): () => void { + listeners.add(fn) + return (): void => { + listeners.delete(fn) + } +} diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index f1708be..4d72fd9 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -32,6 +32,7 @@ import controlsIcon from '../../assets/controls.svg' import PermissionErrorModal from '../../components/modals/PermissionErrorModal' import StorageLimitModal from '../../components/modals/StorageLimitModal' import { generateUUID, generateRandomUsernames } from '../../utils/misc' +import { prefetchElementModule } from '../../elementModules' import { pollUntilElement, getShapeTextNodes, @@ -69,7 +70,6 @@ import { import { isWelcomeComponent, playWelcomeSketchEntrance, - playWelcomeSketchExit, } from '../../utils/welcomeSketch' import { useDrawingModes } from '../../hooks/useDrawingModes' import { useElementDefaults } from '../../hooks/useElementDefaults' @@ -219,9 +219,9 @@ const BoardViewPage: React.FC = (props) => { ) // Guards the one-shot welcome-sketch soft-land entrance. const welcomeEntrancePlayedRef = useRef(false) - // Guards the one-shot welcome-sketch exit so a burst of first adds only - // fades the sketch out once. - const welcomeDismissInFlightRef = useRef(false) + // Guards the one-shot welcome-sketch promotion so a burst of first adds only + // promotes the sketch into real content once. + const welcomePromotedRef = useRef(false) // eslint-disable-next-line @typescript-eslint/no-explicit-any const twoJSInstanceRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -440,6 +440,45 @@ const BoardViewPage: React.FC = (props) => { isPersistedRef.current = isPersisted }, [isPersisted]) + // Warm the core element chunks once the board is idle after mount, so the + // first use of any of them finds its chunk already cached (the per-arm + // prefetch in primary.tsx still covers the quick-draw race). Best-effort: + // gated on idle so it never competes with initial paint/board-load. + // + // `groupobject` is included because group-selection lazy-loads it on + // demand; without warming, the group can't mount until its ~580ms (Slow 4G) + // chunk arrives, leaving the selected elements invisible — the group-select + // "blink". Geo-only components (point/area/route/geoText/cluster) are left + // out; warm them separately if/when geo mode needs it. + useEffect(() => { + const CORE_ELEMENT_CHUNKS = [ + 'rectangle', + 'circle', + 'diamond', + 'arrowLine', + 'divider', + 'pencil', + 'newText', + 'groupobject', + ] + const warm = (): void => { + CORE_ELEMENT_CHUNKS.forEach(prefetchElementModule) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ric = (window as any).requestIdleCallback as + | ((cb: () => void, opts?: { timeout: number }) => number) + | undefined + if (ric) { + const handle = ric(warm, { timeout: 3000 }) + return (): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).cancelIdleCallback?.(handle) + } + } + const t = setTimeout(warm, 1500) + return (): void => clearTimeout(t) + }, []) + useEffect(() => { console.log('change in componentStore in Board', componentStore) stateRefForComponentStore.current = componentStore @@ -548,14 +587,15 @@ const BoardViewPage: React.FC = (props) => { } } - // Gently fades + lifts the welcome-sketch elements out, then clears them - // from the store, on the user's first real interaction. The exit tween runs - // node-direct via the same primitive as the soft-land entrance (and - // supersedes it if still in flight); the store sweep is gated on the tween - // finishing so elements aren't yanked mid-fade. Guarded so a burst of first - // adds only triggers one dismissal. - const dismissWelcomeSketch = (): void => { - if (welcomeDismissInFlightRef.current) return + // On the user's first real interaction, the welcome sketch stops being + // onboarding scaffolding and becomes the user's own content: we strip the + // `isWelcome`/`welcomeRole` tags so the elements are no longer filtered out + // of draft saves + share-time persistence (see useLocalDraftPersistence + + // the persist filter below). They simply stay on the canvas as-is — no fade, + // no removal — and are saved, persisted, and deletable like anything the + // user drew. Guarded so a burst of first adds only promotes once. + const promoteWelcomeSketch = (): void => { + if (welcomePromotedRef.current) return const welcomeIds = Object.keys( stateRefForComponentStore.current ).filter((id) => @@ -563,47 +603,41 @@ const BoardViewPage: React.FC = (props) => { ) if (welcomeIds.length === 0) return - welcomeDismissInFlightRef.current = true + welcomePromotedRef.current = true + // The sketch now lives in the draft, so it must not be re-seeded on the + // next visit. localStorage.setItem(WELCOME_DISMISSED_KEY, '1') - const sweepStore = (): void => { - const two = twoJSInstanceRef.current - const next = { ...stateRefForComponentStore.current } - welcomeIds.forEach((id) => { - delete next[id] - // The exit tween only fades opacity to 0; the node stays in the - // Two.js scene and remains hit-testable. Remove it outright so a - // dismissed welcome element (e.g. the "Drag me" rect) can't be - // clicked after the user draws. The element's React wrapper never - // unmounts, so nothing else removes it from the scene. - if (two) { - const el = two.scene.children.find( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (child: any) => child?.elementData?.id === id - ) - if (el) two.remove(el) - } - window.dispatchEvent( - new CustomEvent('elementRemoved', { detail: { id } }) - ) - }) - stateRefForComponentStore.current = next - setComponentStore(next) - if (two) { - try { - two.update() - } catch { - // See CLAUDE.md "Two.js scene.subtractions Pitfall": if the - // render throws, the bad subtraction stays queued and every - // later two.update() repeats the crash. Clear it so the - // canvas keeps rendering. - two.scene.subtractions.length = 0 - two.scene._flagSubtractions = false - } + const two = twoJSInstanceRef.current + const next = { ...stateRefForComponentStore.current } + welcomeIds.forEach((id) => { + const comp = next[id] + if (!comp) return + // Drop only the welcome tags; everything else (opacity, text + // content, etc.) carries over so the element renders unchanged but + // isWelcomeComponent() no longer matches it. + const { + isWelcome: _isWelcome, + welcomeRole: _welcomeRole, + ...restMeta + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = (comp.metadata ?? {}) as any + next[id] = { ...comp, metadata: restMeta } + // Keep the live Two.js node's bookkeeping in sync in case anything + // reads the welcome flag off elementData. + const el = two?.scene.children.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (child: any) => child?.elementData?.id === id + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const meta = (el as any)?.elementData?.metadata + if (meta) { + delete meta.isWelcome + delete meta.welcomeRole } - } - - playWelcomeSketchExit(twoJSInstanceRef.current, welcomeIds, sweepStore) + }) + stateRefForComponentStore.current = next + setComponentStore(next) } // Records ADD action, updates store and syncs to DB @@ -646,11 +680,12 @@ const BoardViewPage: React.FC = (props) => { safeInfo.position = maxPos + 1 } - // User's first real element dismisses the onboarding sketch. Welcome - // elements are seeded via setComponentStore directly (never through - // this path), so any add here is by definition "real" user content. + // User's first real element promotes the onboarding sketch into real, + // persisted content (it stays on the canvas rather than vanishing). + // Welcome elements are seeded via setComponentStore directly (never + // through this path), so any add here is by definition "real" content. if (!isWelcomeComponent(safeInfo as ComponentRecord)) { - dismissWelcomeSketch() + promoteWelcomeSketch() } // Trigger background board creation on first interaction @@ -802,6 +837,12 @@ const BoardViewPage: React.FC = (props) => { skipHistory: boolean = false, syncDefaults: boolean = false ) => { + // Changing a property of a welcome element counts as a first real + // interaction: promote the sketch into persisted content and spin up the + // background board, same as the shapes toolbar. Both are one-shot. + promoteWelcomeSketch() + ensureBackgroundBoard() + const userId = localStorage.getItem('userId') if (!skipHistory) { @@ -858,6 +899,12 @@ const BoardViewPage: React.FC = (props) => { x: number, y: number ) => { + // Dragging a welcome element counts as a first real interaction: promote + // the sketch into persisted content and spin up the background board, + // same as drawing via the shapes toolbar. Both are one-shot/idempotent. + promoteWelcomeSketch() + ensureBackgroundBoard() + const userId = localStorage.getItem('userId') recordToHistoryLog({ @@ -1144,14 +1191,22 @@ const BoardViewPage: React.FC = (props) => { const componentId = sel?.group?.data?.elementData?.id const existingMetadata = sel?.group?.data?.elementData?.metadata ?? {} + const updatedMetadata = { + ...existingMetadata, + fontSize: textSize, + // Reconstruct the raw multiline string from every line node, + // not just line 1 — otherwise a reload would drop lines 2..N. + content: nodes.map((node) => node.value).join('\n'), + } + // Keep the in-place elementData.metadata current too. Other property + // handlers (e.g. opacity in applyProperty) read it as the merge base; if + // left stale they'd write the OLD fontSize back to the store and the + // resize would silently revert on reload. + if (sel?.group?.data?.elementData) { + sel.group.data.elementData.metadata = updatedMetadata + } updateComponentBulkPropertiesInLocalStore(componentId, { - metadata: { - ...existingMetadata, - fontSize: textSize, - // Reconstruct the raw multiline string from every line node, - // not just line 1 — otherwise a reload would drop lines 2..N. - content: nodes.map((node) => node.value).join('\n'), - }, + metadata: updatedMetadata, }) twoJSInstance?.update() syncOpenTextarea({ fontSize: textSize }) @@ -1246,14 +1301,20 @@ const BoardViewPage: React.FC = (props) => { const componentId = sel?.group?.data?.elementData?.id const existingMetadata = sel?.group?.data?.elementData?.metadata ?? {} + const updatedMetadata = { + ...existingMetadata, + textFontFamily: fontFamily, + // Reconstruct the raw multiline string from every line node, + // not just line 1 — otherwise a reload would drop lines 2..N. + content: nodes.map((node) => node.value).join('\n'), + } + // Keep in-place elementData.metadata current so later handlers (opacity, + // etc.) merge onto the new family instead of writing a stale one back. + if (sel?.group?.data?.elementData) { + sel.group.data.elementData.metadata = updatedMetadata + } updateComponentBulkPropertiesInLocalStore(componentId, { - metadata: { - ...existingMetadata, - textFontFamily: fontFamily, - // Reconstruct the raw multiline string from every line node, - // not just line 1 — otherwise a reload would drop lines 2..N. - content: nodes.map((node) => node.value).join('\n'), - }, + metadata: updatedMetadata, }) twoJSInstance?.update() syncOpenTextarea({ fontFamily }) diff --git a/src/views/Embeddable/embeddable.tsx b/src/views/Embeddable/embeddable.tsx new file mode 100644 index 0000000..f3dce4b --- /dev/null +++ b/src/views/Embeddable/embeddable.tsx @@ -0,0 +1,236 @@ +import React, { useEffect } from 'react' +import type { ReactElement, ReactNode } from 'react' +import { Link } from 'react-router-dom' +import routes from '../../routes' +import { useMediaQueryUtils } from '../../constants/exportHooks' + +const ChevronLeft = (): ReactElement => ( + + + +) + +// Small, dependency-free code block. The page is app-only (not part of the +// published library surface in lib.ts), so standard Tailwind utilities are safe +// here — no consumer purge to worry about. +const CodeBlock = ({ children }: { children: ReactNode }): ReactElement => ( +
+        {children}
+    
+) + +const Section = ({ + title, + children, +}: { + title: string + children: ReactNode +}): ReactElement => ( +
+

+ {title} +

+ {children} +
+) + +const EmbeddablePage: React.FC = () => { + const { isMobile } = useMediaQueryUtils() + + // Set the document title + meta description for this route. The app is an + // SPA, so the static index.html title is shared across routes — updating it + // here gives this page a unique title/snippet when crawled and when shared. + useEffect(() => { + const prevTitle = document.title + document.title = + 'Embeddable Whiteboard for React Apps — Craftbase' + + const description = + 'Embed Craftbase, an open-source whiteboard canvas, into your React app with a single component. Whiteboard data lives in the browser localStorage — no backend required to get started.' + let meta = document.querySelector( + 'meta[name="description"]' + ) as HTMLMetaElement | null + const createdMeta = !meta + if (!meta) { + meta = document.createElement('meta') + meta.name = 'description' + document.head.appendChild(meta) + } + const prevDescription = meta.content + meta.content = description + + return (): void => { + document.title = prevTitle + if (createdMeta) { + meta?.remove() + } else if (meta) { + meta.content = prevDescription + } + } + }, []) + + return ( +
+ {/* Nav */} + + + {/* Body */} +
+ {/* Header */} +
+

+ Embeddable Whiteboard for React +

+

+ Craftbase is an open-source, embeddable whiteboard canvas + you can drop into any React app as a single component. + Mount the {''}{' '} + and you get a full sketching surface — shapes, arrows, + text, freehand drawing, pan and zoom — rendered with + Two.js. No backend is required to get started: your + whiteboard data lives in the browser's{' '} + localStorage. +

+
+ +
+

+ Add Craftbase as a dependency. During local development + you can link the package directly from a sibling + checkout: +

+ {`// package.json +{ + "dependencies": { + "craftbase": "link:../craftbase" + } +}`} +
+ +
+

+ Import the Board{' '} + component and render it inside a sized container. That's + the whole integration — Craftbase owns the canvas, tools + and interactions. +

+ {`import { Board } from 'craftbase' + +export default function Whiteboard() { + return ( +
+ +
+ ) +}`}
+
+ +
+

+ By default Craftbase runs in local + mode. Everything a user draws is kept in React + state and continuously saved to the browser's{' '} + localStorage as a + draft. This means: +

+
    +
  • + No database, server or account is needed to start — + the canvas works fully offline. +
  • +
  • + The board is restored automatically on reload from + the saved localStorage draft. +
  • +
  • + Data is scoped to the user's browser and origin, so + it is private to that device until you choose to + persist or share it. +
  • +
+

+ Because the draft is just localStorage, clearing the + browser's site data (or opening the app in a different + browser/device) starts a fresh board. When you're ready + to sync across devices, Craftbase can be wired to a + backend, but that's entirely opt-in. +

+
+ +
+

+ Craftbase ships TypeScript source ( + .ts/ + .tsx). Make sure + your bundler compiles it and that Tailwind scans + Craftbase's classes so they survive purging: +

+ {`// tailwind.config.js +export default { + content: [ + './src/**/*.{ts,tsx}', + './node_modules/craftbase/src/**/*.{ts,tsx}', + ], +} + +// vite.config.js — let Vite handle Craftbase's TS source +export default { + optimizeDeps: { exclude: ['craftbase'] }, +}`} +
+ + {/* CTA */} +
+
+ Want the full API and extension points? Browse the source + and docs on GitHub. +
+ + View on GitHub → + +
+
+
+ ) +} + +export default EmbeddablePage diff --git a/src/views/Embeddable/errorBoundary.tsx b/src/views/Embeddable/errorBoundary.tsx new file mode 100644 index 0000000..f478134 --- /dev/null +++ b/src/views/Embeddable/errorBoundary.tsx @@ -0,0 +1,34 @@ +import React, { type ReactNode } from 'react' + +interface Props { + children?: ReactNode +} + +interface State { + hasError: boolean +} + +class ErrorBoundaryEmbeddableView extends React.Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(_error: unknown): State { + return { hasError: true } + } + + override componentDidCatch( + _error: Error, + _errorInfo: React.ErrorInfo + ): void {} + + override render(): ReactNode { + if (this.state.hasError) { + return

Couldn't load this page. Something went wrong

+ } + return this.props.children + } +} + +export default ErrorBoundaryEmbeddableView diff --git a/src/views/Embeddable/index.tsx b/src/views/Embeddable/index.tsx new file mode 100644 index 0000000..d4de71b --- /dev/null +++ b/src/views/Embeddable/index.tsx @@ -0,0 +1,15 @@ +import React, { Suspense } from 'react' +import ErrorBoundary from './errorBoundary' +import Spinner from '../../components/common/spinner' + +const EmbeddablePage = React.lazy(() => import('./embeddable')) + +const EmbeddableViewContainer: React.FC = (props) => ( + }> + + + + +) + +export default EmbeddableViewContainer diff --git a/tests/e2e/copy-paste.spec.js b/tests/e2e/copy-paste.spec.js index e52c2d1..3949b37 100644 --- a/tests/e2e/copy-paste.spec.js +++ b/tests/e2e/copy-paste.spec.js @@ -180,14 +180,14 @@ test.describe('Copy-paste', () => { * Flow exercised: * 1. Draw circle (default fill #f4f4f2) * 2. Click circle → floating toolbar opens - * 3. Click #FFAB00 swatch in Background section → fill updated in store + * 3. Click #FFF0B3 swatch in Background section → fill updated in store * 4. Cmd+C → Cmd+V on empty area - * 5. Pasted circle's fill should be #FFAB00, not the default + * 5. Pasted circle's fill should be #FFF0B3, not the default */ test('pasting a circle with user-modified fill preserves the new fill', async ({ page, }) => { - const NEW_FILL = '#FFAB00' + const NEW_FILL = '#FFF0B3' const box = await getCanvasBox(page) const { cx, cy } = safeArea(box) diff --git a/tests/e2e/group-apply-property.spec.js b/tests/e2e/group-apply-property.spec.js index 1d005b1..cf342c0 100644 --- a/tests/e2e/group-apply-property.spec.js +++ b/tests/e2e/group-apply-property.spec.js @@ -41,7 +41,7 @@ import { // text fill #3A342C). NOTE: the Fill picker uses fillEssentialShades (which // dropped green #36B37E for a transparent swatch), while stroke/text use the // shared essentialShades — so FILL_COLOR must come from fillEssentialShades. -const FILL_COLOR = '#FFAB00' +const FILL_COLOR = '#FFF0B3' const STROKE_COLOR = '#0065FF' const TEXT_COLOR = '#FF5630'