{
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'