From 210dced1aee847a188c2a7725b39e1ff2553ceed Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Fri, 5 Jun 2026 20:43:00 +0530 Subject: [PATCH 1/3] update: add extract as svg feature --- src/components/canvasContextMenu.tsx | 107 +++++++++++++++++++++++++++ src/hooks/useElementDefaults.ts | 17 +++++ src/newCanvas.tsx | 68 +++++++++++++++++ src/utils/exportSelectionAsSvg.ts | 103 ++++++++++++++++++++++++++ src/utils/exportViewport.ts | 60 +-------------- src/utils/svgExportShared.ts | 63 ++++++++++++++++ src/views/Board/board.tsx | 4 + 7 files changed, 364 insertions(+), 58 deletions(-) create mode 100644 src/components/canvasContextMenu.tsx create mode 100644 src/utils/exportSelectionAsSvg.ts create mode 100644 src/utils/svgExportShared.ts diff --git a/src/components/canvasContextMenu.tsx b/src/components/canvasContextMenu.tsx new file mode 100644 index 0000000..905a0ec --- /dev/null +++ b/src/components/canvasContextMenu.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react' +import type { ReactElement } from 'react' + +import Portal from './common/portal' + +interface CanvasContextMenuProps { + x: number + y: number + onClose: () => void + onExportSvg: () => void +} + +const MENU_WIDTH = 220 +const MENU_MARGIN = 8 + +/** + * Small fixed-position menu opened on canvas right-click / two-finger tap. + * Closes on outside click or Escape. Positioned at the cursor and clamped to + * the viewport so it never overflows off-screen. + */ +const CanvasContextMenu = ({ + x, + y, + onClose, + onExportSvg, +}: CanvasContextMenuProps): ReactElement => { + const refNode = useRef(null) + const [height, setHeight] = useState(0) + + useEffect(() => { + if (refNode.current) setHeight(refNode.current.offsetHeight) + }, []) + + useEffect(() => { + const handleClick = (e: MouseEvent): void => { + if (refNode.current?.contains(e.target as Node)) return + onClose() + } + const handleKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('mousedown', handleClick, false) + document.addEventListener('keydown', handleKey, false) + return (): void => { + document.removeEventListener('mousedown', handleClick, false) + document.removeEventListener('keydown', handleKey, false) + } + }, [onClose]) + + const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_MARGIN) + const top = Math.min( + y, + window.innerHeight - (height || 60) - MENU_MARGIN + ) + + return ( + +
+ +
+
+ ) +} + +export default CanvasContextMenu diff --git a/src/hooks/useElementDefaults.ts b/src/hooks/useElementDefaults.ts index 10bf788..6c69572 100644 --- a/src/hooks/useElementDefaults.ts +++ b/src/hooks/useElementDefaults.ts @@ -59,6 +59,9 @@ export interface ElementDefaultsApi extends ElementDefaultsState { setDefaultTextColorInBoard: (val: string) => void setDefaultTextSizeInBoard: (val: TextSizeLabel) => void setDefaultTextFontFamilyInBoard: (val: string) => void + // Restore every default to its factory value (used by "clear canvas" so a + // leaked default like linewidth:0 doesn't carry over to the next drawing). + resetDefaults: () => void } export function useElementDefaults(): ElementDefaultsApi { @@ -137,6 +140,19 @@ export function useElementDefaults(): ElementDefaultsApi { const setDefaultTextFontFamilyInBoard = (val: string): void => setDefaultTextFontFamily(val) + // Reset all defaults to INITIAL_DEFAULTS. The persistence effect above then + // rewrites localStorage to the factory values on the resulting state change. + const resetDefaults = (): void => { + setDefaultFill(INITIAL_DEFAULTS.defaultFill) + setDefaultStrokeColor(INITIAL_DEFAULTS.defaultStrokeColor) + setDefaultLinewidth(INITIAL_DEFAULTS.defaultLinewidth) + setDefaultStrokeType(INITIAL_DEFAULTS.defaultStrokeType) + setDefaultOpacity(INITIAL_DEFAULTS.defaultOpacity) + setDefaultTextColor(INITIAL_DEFAULTS.defaultTextColor) + setDefaultTextSize(INITIAL_DEFAULTS.defaultTextSize) + setDefaultTextFontFamily(INITIAL_DEFAULTS.defaultTextFontFamily) + } + return { defaultFill, defaultStrokeColor, @@ -162,5 +178,6 @@ export function useElementDefaults(): ElementDefaultsApi { setDefaultTextColorInBoard, setDefaultTextSizeInBoard, setDefaultTextFontFamilyInBoard, + resetDefaults, } } diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 20866c1..1ba0d29 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -12,6 +12,7 @@ import React, { useEffect, useState, useRef, + useCallback, Suspense, type MutableRefObject, type ReactNode, @@ -78,6 +79,8 @@ import { growShapeToFitText, usableTextWidth } from './utils/shapeTextFit' import { isSelectPanMode, isPanMode } from './utils/drawModeUtils' import { createDiamondPath } from './factory/diamond' import { useCanvasClipboard } from './hooks/useCanvasClipboard' +import { exportSelectionAsSvg } from './utils/exportSelectionAsSvg' +import CanvasContextMenu from './components/canvasContextMenu' import { ElementRenderWrapper, GroupRenderWrapper, @@ -2685,6 +2688,10 @@ const Canvas: React.FC = (props) => { const [zuiInstance, setZuiInstance] = useState(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const [onGroup, setOnGroup] = useState(null) + // Right-click / two-finger context menu position (null = closed). + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>( + null + ) const [componentsToRender, setComponentsToRender] = useState< React.ComponentType[] >([]) @@ -3067,6 +3074,56 @@ const Canvas: React.FC = (props) => { } }, [undoLastAction, redoLastAction, enableTextDrawMode]) + // Export the active selection (marquee group or single element) as a + // standalone .svg. getSelectedGroup() unifies both selection mechanisms. + const exportActiveSelection = useCallback(async () => { + const group = zuiInstanceRef.current?.getSelectedGroup?.() + if (!group) return + try { + await exportSelectionAsSvg(group) + } catch (err) { + console.warn('Export selection as SVG failed', err) + } + }, []) + + // Right-click (mouse) and two-finger trackpad tap both fire the native + // 'contextmenu' event. Suppress the OS menu; if something is selected, open + // our menu at the cursor. Cmd/Ctrl+Shift+D triggers the same export. + useEffect(() => { + const root = document.getElementById('main-two-root') + if (!root) return + + const onContextMenu = (evt: MouseEvent) => { + evt.preventDefault() + const group = zuiInstanceRef.current?.getSelectedGroup?.() + if (group) { + setCtxMenu({ x: evt.clientX, y: evt.clientY }) + } else { + setCtxMenu(null) + } + } + + const onExportKeyDown = (evt: KeyboardEvent) => { + if ( + evt.key.toLowerCase() !== 'd' || + !(evt.ctrlKey || evt.metaKey) || + !evt.shiftKey + ) + return + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + evt.preventDefault() + void exportActiveSelection() + } + + root.addEventListener('contextmenu', onContextMenu) + window.addEventListener('keydown', onExportKeyDown) + return () => { + root.removeEventListener('contextmenu', onContextMenu) + window.removeEventListener('keydown', onExportKeyDown) + } + }, [exportActiveSelection]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const setOnGroupHandler = (obj: any) => { setOnGroup(obj) @@ -3229,6 +3286,17 @@ const Canvas: React.FC = (props) => { ))} + {ctxMenu && ( + setCtxMenu(null)} + onExportSvg={() => { + setCtxMenu(null) + void exportActiveSelection() + }} + /> + )} ) } diff --git a/src/utils/exportSelectionAsSvg.ts b/src/utils/exportSelectionAsSvg.ts new file mode 100644 index 0000000..5f8e49e --- /dev/null +++ b/src/utils/exportSelectionAsSvg.ts @@ -0,0 +1,103 @@ +// Export the currently-selected element(s) as a standalone, tightly-cropped +// .svg file with a transparent background and no watermark. +// +// Two.js runs the SVG renderer, so the selected group already has a live +// rendered node at group._renderer.elem. We clone that , drop it inside a +// fresh , and crop to its content via getBBox() on a transform-free wrapper +// — this sidesteps the ZUI zoom/pan transform entirely: the clone keeps its own +// (unzoomed, scene-space) transform, getBBox reports its bounds in that same +// space, and using those bounds as the viewBox yields a 1:1 portable asset. + +import { SVG_NS, embedFonts } from './svgExportShared' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GroupLike = any + +/** + * Export a Two.js group (or single-element group) as a downloaded .svg file. + * Throws if the group has no rendered SVG node or has zero size. + */ +export async function exportSelectionAsSvg(group: GroupLike): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gEl: SVGGElement | undefined = (group as any)?._renderer?.elem + if (!gEl) throw new Error('No rendered SVG node found for the selection') + + const clone = gEl.cloneNode(true) as SVGGElement + + // A marquee group's also contains the transparent marquee-sized rect and + // the visible selection border/handles (added as children of the group). For + // a single-element selection the group's is already clean (its selection + // UI lives separately in the scene), so we only prune marquee groups. Keep + // only the direct children that map to real content elements (those carrying + // elementData.id); everything else is selection chrome. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((group as any)?.elementData?.isGroupSelector) { + const keepIds = new Set( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((group as any).children || []) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((c: any) => c?.elementData?.id) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((c: any) => c?._renderer?.elem?.id) + .filter((id: unknown): id is string => typeof id === 'string') + ) + Array.from(clone.children).forEach((node) => { + if (!keepIds.has(node.id)) node.remove() + }) + } + + const svg = document.createElementNS(SVG_NS, 'svg') + svg.setAttribute('xmlns', SVG_NS) + // Transform-free wrapper so getBBox reports bounds in the svg's user space, + // accounting for the clone's own transform via descendant geometry. + const wrapper = document.createElementNS(SVG_NS, 'g') + wrapper.appendChild(clone) + svg.appendChild(wrapper) + + // getBBox requires the node be laid out (in the DOM, not display:none). + svg.setAttribute( + 'style', + 'position:fixed;left:-99999px;top:-99999px;opacity:0;pointer-events:none' + ) + document.body.appendChild(svg) + + try { + const bb = wrapper.getBBox() + if (bb.width === 0 || bb.height === 0) { + throw new Error('Selection has zero size; nothing to export') + } + + svg.setAttribute('width', String(bb.width)) + svg.setAttribute('height', String(bb.height)) + svg.setAttribute( + 'viewBox', + `${bb.x} ${bb.y} ${bb.width} ${bb.height}` + ) + + // Font fetch is async; keep the element hidden (style attr) until the + // synchronous serialize below so it never flashes on-screen. + await embedFonts(svg) + + // Strip the off-screen hiding style only from the serialized output. + svg.removeAttribute('style') + const svgString = new XMLSerializer().serializeToString(svg) + downloadSvg(svgString) + } finally { + if (svg.parentNode) svg.parentNode.removeChild(svg) + } +} + +/** Trigger a browser download of the SVG markup as a .svg file. */ +function downloadSvg(svgString: string): void { + const blob = new Blob([svgString], { + type: 'image/svg+xml;charset=utf-8', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `craftbase-selection-${Date.now()}.svg` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/src/utils/exportViewport.ts b/src/utils/exportViewport.ts index 9de346c..280c14a 100644 --- a/src/utils/exportViewport.ts +++ b/src/utils/exportViewport.ts @@ -9,14 +9,12 @@ // stamp a screen-space watermark, then draw the SVG onto a and // download it as PNG. -const SVG_NS = 'http://www.w3.org/2000/svg' +import { SVG_NS, embedFonts } from './svgExportShared' + const CANVAS_BG = '#f5f0e8' // --color-canvas (App.css) const DOT_COLOR = '#c4b89a' // radial-gradient dot color (App.css) const DOT_TILE = 24 // background-size: 24px 24px (App.css) const WATERMARK_TEXT = 'Made with craftbase.org' -// Fonts used for canvas text. --font-sketch: 'Caveat' (App.css). Embedded so -// rasterized text matches the screen instead of falling back to a system font. -const FONT_FAMILIES = ['Caveat'] const MAX_DPR = 2 // cap device-pixel scaling to bound output file size /** @@ -109,60 +107,6 @@ function appendWatermark( svg.appendChild(text) } -/** - * Inline the Google web font(s) as base64 inside a
{title && ( - + {title} )} diff --git a/src/hooks/useComponentHistory.ts b/src/hooks/useComponentHistory.ts index 20724f0..62dfd2c 100644 --- a/src/hooks/useComponentHistory.ts +++ b/src/hooks/useComponentHistory.ts @@ -760,6 +760,18 @@ export function useComponentHistory({ // Only UPDATE_VERTICES and UPDATE_BULK need extra capture — for // ADD/DELETE/BATCH the original entry already contains everything redo needs. const captureNextState = (entry: HistoryEntry): HistoryEntry => { + if (entry.action === 'ADD') { + // The ADD entry's componentInfo is snapshotted at create time. For + // arrows (and any element whose post-create geometry is applied with + // skipHistory), that snapshot is stale — e.g. an arrow is pre-created + // off-screen at -9999 with zero-length vertices, then drawn later. + // Re-read the live store here (still present, since undo's + // applyRemove runs after this) so redo re-inserts the final geometry. + const current = stateRefForComponentStore.current[entry.id] + return current + ? { ...entry, componentInfo: { ...current } } + : entry + } if (entry.action === 'UPDATE_VERTICES') { const current = stateRefForComponentStore.current[entry.id] return { diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 1ba0d29..a2b241a 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -2342,7 +2342,19 @@ function addZUI( lastTouch.clientY ) - if (!handleHit) { + // A multi-element group uses the older objectSelector path (not + // selectionController), so handleHit is false for it. Without this, + // the clearSelector below hides the group's dashed box the instant + // the drag begins on mobile. Skip the clear when the finger lands on + // the group object so its selector stays visible through the drag. + const groupHit = ( + document.elementFromPoint( + lastTouch.clientX, + lastTouch.clientY + ) as Element | null + )?.closest('[data-label="groupobject_coord"]') + + if (!handleHit && !groupHit) { // Clear any previous selection before processing the new tap. // On desktop this happens via focus/blur, but synthetic mouse events // don't transfer browser focus on mobile, so we do it explicitly here diff --git a/src/types/board.ts b/src/types/board.ts index 4b0715a..c4569db 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -205,8 +205,16 @@ export interface BoardContextValue { stateRefForComponentStore: MutableRefObject // Property application - applyProperty: (name: string, value: unknown) => void - applyGroupProperty: (name: string, value: unknown) => void + applyProperty: ( + name: string, + value: unknown, + opts?: { preview?: boolean } + ) => void + applyGroupProperty: ( + name: string, + value: unknown, + opts?: { preview?: boolean } + ) => void // Element defaults (read sites: ElementPropertiesToolbar, primary sidebar, factories) defaultFill: string diff --git a/src/utils/applyGroupProperty.ts b/src/utils/applyGroupProperty.ts index 6269728..13affb5 100644 --- a/src/utils/applyGroupProperty.ts +++ b/src/utils/applyGroupProperty.ts @@ -205,7 +205,11 @@ export function createApplyGroupProperty(deps: ApplyGroupPropertyDeps) { return function applyGroupProperty( propertyKey: GroupPropertyKey | string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any + value: any, + // `preview` applies the change to the live Two.js scene only, skipping + // the store/history writes — used for continuous slider drags. The final + // value commits normally (no preview) on release. + opts?: { preview?: boolean } ): void { const { selectedGroup, @@ -323,11 +327,20 @@ export function createApplyGroupProperty(deps: ApplyGroupPropertyDeps) { if (propertyKey === 'opacity') { const sceneLeaf = sceneEl?.children?.[0] if (sceneLeaf) sceneLeaf.opacity = value + // Dim embedded text alongside the shape leaf (rect/diamond/ + // circle-with-text keep text in a separate text-layer node). + findTextNodesInside(sceneEl).forEach((t) => (t.opacity = value)) if (coreObj) { coreObj.opacity = 1 const coreLeaf = coreObj?.children?.[0] if (coreLeaf) coreLeaf.opacity = value + findTextNodesInside(coreObj).forEach( + (t) => (t.opacity = value) + ) } + // Live drag preview: scene only, defer the store/history write + // to the commit on release. + if (opts?.preview) return const existingMeta = sceneEl?.elementData?.metadata const safeMeta = existingMeta && !Array.isArray(existingMeta) diff --git a/src/utils/applyProperty.ts b/src/utils/applyProperty.ts index 3772075..49d8c2f 100644 --- a/src/utils/applyProperty.ts +++ b/src/utils/applyProperty.ts @@ -73,7 +73,12 @@ export function createApplyProperty(deps: ApplyPropertyDeps) { return function applyProperty( propertyKey: PropertyKey | string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any + value: any, + // `preview` applies the change to the live Two.js scene only, skipping + // the store/history/default writes — used for continuous slider drags so + // the element updates in real time without spamming the undo stack. The + // final value is committed normally (no preview) on release. + opts?: { preview?: boolean } ): void { const { selectedComponent, @@ -88,23 +93,26 @@ export function createApplyProperty(deps: ApplyPropertyDeps) { setDefaultStrokeColor, setDefaultLinewidth, setDefaultStrokeType, - setDefaultOpacity, setDefaultTextColor, setDefaultTextSize, setDefaultTextFontFamily, } = deps - // 1. Update the matching default. - if (propertyKey === 'fill') setDefaultFill(value) - else if (propertyKey === 'stroke') setDefaultStrokeColor(value) - else if (propertyKey === 'linewidth') setDefaultLinewidth(value) - else if (propertyKey === 'strokeType') - setDefaultStrokeType(value === 'solid' ? null : value) - else if (propertyKey === 'opacity') setDefaultOpacity(value) - else if (propertyKey === 'textColor') setDefaultTextColor(value) - else if (propertyKey === 'textSize') setDefaultTextSize(value) - else if (propertyKey === 'textFontFamily') - setDefaultTextFontFamily(value) + // 1. Update the matching default. Opacity is deliberately excluded — it + // is a per-element property only and must never persist as a default, + // otherwise drawing a new shape after dimming one (e.g. to 0%) would + // produce an invisible shape. Skipped entirely in preview mode. + if (!opts?.preview) { + if (propertyKey === 'fill') setDefaultFill(value) + else if (propertyKey === 'stroke') setDefaultStrokeColor(value) + else if (propertyKey === 'linewidth') setDefaultLinewidth(value) + else if (propertyKey === 'strokeType') + setDefaultStrokeType(value === 'solid' ? null : value) + else if (propertyKey === 'textColor') setDefaultTextColor(value) + else if (propertyKey === 'textSize') setDefaultTextSize(value) + else if (propertyKey === 'textFontFamily') + setDefaultTextFontFamily(value) + } // 2. If nothing is selected, we're done. if (!selectedComponent) return @@ -232,13 +240,32 @@ export function createApplyProperty(deps: ApplyPropertyDeps) { strokeType: dbValue, }) } else if (propertyKey === 'opacity') { - if (shapeData) shapeData.opacity = value - const existingMeta = elementData?.metadata ?? {} - const updatedMeta = { ...existingMeta, opacity: value } - if (elementData) elementData.metadata = updatedMeta - updateComponentBulkPropertiesInLocalStore(id, { - metadata: updatedMeta, - }) + // Apply opacity at the GROUP level, not the shape leaf. The leaf + // path is double-referenced in group.children (the *-with-text + // components unshift the factory's already-added shape), which + // leaves leaf-level opacity flags unprocessed on render — so a leaf + // write only appears after the next full repaint (e.g. on deselect). + // The group's own opacity always repaints, and it uniformly dims the + // shape plus any embedded text-layer nodes in one shot. + const groupObj = selectedComponent?.group?.data + if (groupObj) groupObj.opacity = value + // Neutralize any leaf/text opacity so it doesn't compound with the + // group's (e.g. shapes mounted before this change carried leaf-level + // opacity). + if (shapeData) shapeData.opacity = 1 + getShapeTextNodes(selectedComponent?.group?.data).forEach( + (n) => (n.opacity = 1) + ) + // Preview = live drag: mutate the scene only, defer the + // store/history write to the commit on release. + if (!opts?.preview) { + const existingMeta = elementData?.metadata ?? {} + const updatedMeta = { ...existingMeta, opacity: value } + if (elementData) elementData.metadata = updatedMeta + updateComponentBulkPropertiesInLocalStore(id, { + metadata: updatedMeta, + }) + } } twoJSInstance?.update() diff --git a/src/utils/canvasUtils.ts b/src/utils/canvasUtils.ts index 5c5d6cf..91e8f19 100644 --- a/src/utils/canvasUtils.ts +++ b/src/utils/canvasUtils.ts @@ -276,7 +276,15 @@ export function getShapeTextNodes(group: ShapeLike): ShapeLike[] { const layer = findShapeTextLayer(group) const source = layer ? layer.children : group?.children if (!source) return [] - return source.filter( + // `source` is a Two.js `Children` collection (a custom Array subclass with + // no Symbol.species). Calling `.filter` on it directly routes through + // `ArraySpeciesCreate(new Children(0))`, whose constructor mishandles the + // numeric length and seeds the result with a spurious `0`. When there are + // no text nodes that `0` survives, so the filter returns `[0]` instead of + // `[]` — and any caller dereferencing the result (`n.opacity`, `n.fill`) + // throws `Cannot create property … on number '0'`. Copy to a plain array + // first so the filter is well-behaved and returns `[]` for text-less shapes. + return Array.from(source as ArrayLike).filter( (c: ShapeLike) => typeof c?.value === 'string' ) } diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index 6ef9bfa..6111abf 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -711,7 +711,7 @@ const BoardViewPage: React.FC = (props) => { ...(componentType === 'geoText' && { resist: DEFAULT_GEO_RESIST, }), - opacity: defaultOpacity ?? 1, + opacity: 1, }, x: Math.trunc(x), y: Math.trunc(y), From 2db36ca40091a436959ab390b661759e153486f9 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sat, 6 Jun 2026 13:46:36 +0530 Subject: [PATCH 3/3] update: add opacity property tests and change default font to caveat brush --- index.html | 2 +- src/App.css | 9 +- src/components/elements/geoText.tsx | 10 +- src/components/elements/groupobject.tsx | 5 +- src/components/elements/newText.tsx | 31 ++- src/components/sidebar/elementProperties.tsx | 1 + src/constants/misc.ts | 8 + src/factory/newText.ts | 3 +- src/hooks/useComponentHistory.ts | 15 +- src/hooks/useElementDefaults.ts | 3 +- src/newCanvas.tsx | 6 +- src/utils/canvasUtils.ts | 8 +- src/utils/exportViewport.ts | 3 +- src/utils/htmlToBulletText.ts | 125 +++++++++++ src/utils/svgExportShared.ts | 12 +- src/utils/textLayout.ts | 7 +- src/utils/welcomeSketch.ts | 3 +- tailwind.config.js | 2 +- tests/e2e/opacity.spec.js | 221 +++++++++++++++++++ tests/e2e/text-paste-bullets.spec.js | 65 ++++++ 20 files changed, 513 insertions(+), 26 deletions(-) create mode 100644 src/utils/htmlToBulletText.ts create mode 100644 tests/e2e/opacity.spec.js create mode 100644 tests/e2e/text-paste-bullets.spec.js diff --git a/index.html b/index.html index 57f528f..982af9c 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ Craftbase - minimal whiteboard for builders diff --git a/src/App.css b/src/App.css index 2909780..7a8a9b2 100644 --- a/src/App.css +++ b/src/App.css @@ -1,8 +1,9 @@ :root { --font-ui: 'Geist', system-ui, sans-serif; --font-display: 'Fraunces', Georgia, serif; - --font-sketch: 'Caveat', cursive; + --font-sketch: 'Caveat Brush'; --font-mono: 'Geist Mono', monospace; + --font-caveat-brush: 'Caveat Brush', cursive; --color-canvas: #f5f0e8; --color-sidebar: #ede8dc; @@ -18,6 +19,12 @@ --color-border-card: #c4b89a; } +.caveat-brush-regular { + font-family: 'Caveat Brush', cursive; + font-weight: 400; + font-style: normal; +} + body { margin: 0; font-family: var(--font-ui); diff --git a/src/components/elements/geoText.tsx b/src/components/elements/geoText.tsx index a629f60..d72d64a 100644 --- a/src/components/elements/geoText.tsx +++ b/src/components/elements/geoText.tsx @@ -14,7 +14,10 @@ import { import { lineHeightFor } from '../../utils/textLayout' import { useMediaQueryUtils } from '../../constants/exportHooks' import { computeCounterScale } from '../../utils/counterScale' -import { DEFAULT_GEO_RESIST } from '../../constants/misc' +import { + DEFAULT_GEO_RESIST, + DEFAULT_TEXT_FONT_FAMILY, +} from '../../constants/misc' // GeoText is a clone of NewText (it reuses the same NewTextFactory for // rendering) with one extra behavior: like a point pin, the whole group is @@ -367,7 +370,7 @@ function GeoText(props: ElementProps): ReactElement { input.style.padding = `${vertPad}px 8px` input.style.color = twoText.fill || '#3A342C' input.style.fontSize = `${cssFontSize}px` - input.style.fontFamily = twoText.family || 'Caveat' + input.style.fontFamily = twoText.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = twoText.weight || 'normal' input.style.lineHeight = `${lineH}px` input.style.letterSpacing = '0px' @@ -390,7 +393,8 @@ function GeoText(props: ElementProps): ReactElement { measureSpan.style.visibility = 'hidden' measureSpan.style.whiteSpace = 'pre' measureSpan.style.fontSize = `${cssFontSize}px` - measureSpan.style.fontFamily = twoText.family || 'Caveat' + measureSpan.style.fontFamily = + twoText.family || DEFAULT_TEXT_FONT_FAMILY measureSpan.style.fontWeight = twoText.weight || 'normal' measureSpan.style.lineHeight = `${lineH}px` measureSpan.style.letterSpacing = '0px' diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index 9e6bce2..e216764 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -8,6 +8,7 @@ import Two from 'two.js' import { useBoardContext } from '../../views/Board/boardContext' import getEditComponents from '../utils/editWrapper' import { elementOnBlurHandler } from '../../utils/misc' +import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -377,7 +378,9 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { twoText.alignment = 'center' twoText.baseline = meta.textBaseLine || 'middle' twoText.family = - meta.textFontFamily || meta.textFamily || 'Caveat' + meta.textFontFamily || + meta.textFamily || + DEFAULT_TEXT_FONT_FAMILY coreObject.add(twoText) } diff --git a/src/components/elements/newText.tsx b/src/components/elements/newText.tsx index 3505396..a7e3a91 100644 --- a/src/components/elements/newText.tsx +++ b/src/components/elements/newText.tsx @@ -12,6 +12,8 @@ import { MOBILE_TEXT_SIZES_OBJECT, } from '../../utils/constants' 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 @@ -337,7 +339,7 @@ function NewText(props: ElementProps): ReactElement { input.style.padding = `${vertPad}px 8px` input.style.color = twoText.fill || '#3A342C' input.style.fontSize = `${cssFontSize}px` - input.style.fontFamily = twoText.family || 'Caveat' + input.style.fontFamily = twoText.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = twoText.weight || 'normal' input.style.lineHeight = `${lineH}px` input.style.letterSpacing = '0px' @@ -360,7 +362,8 @@ function NewText(props: ElementProps): ReactElement { measureSpan.style.visibility = 'hidden' measureSpan.style.whiteSpace = 'pre' measureSpan.style.fontSize = `${cssFontSize}px` - measureSpan.style.fontFamily = twoText.family || 'Caveat' + measureSpan.style.fontFamily = + twoText.family || DEFAULT_TEXT_FONT_FAMILY measureSpan.style.fontWeight = twoText.weight || 'normal' measureSpan.style.lineHeight = `${lineH}px` measureSpan.style.letterSpacing = '0px' @@ -395,6 +398,30 @@ function NewText(props: ElementProps): ReactElement { input.addEventListener('input', autoSizeAndCenter) + // Pasting a bulleted list from a rich-text source (Docs, Notion, + // Notes) into this plain textarea would otherwise drop the bullet + // markers — the source's `text/plain` projection omits them. Read + // the `text/html` flavor and rebuild `• `-prefixed lines so list + // structure survives the paste. + input.addEventListener('paste', (event: ClipboardEvent) => { + const html = event.clipboardData?.getData('text/html') + if (!html) return + const converted = htmlToBulletText(html) + if (converted == null) return + + event.preventDefault() + const start = input.selectionStart ?? input.value.length + const end = input.selectionEnd ?? input.value.length + input.value = + input.value.slice(0, start) + + converted + + input.value.slice(end) + const caret = start + converted.length + input.selectionStart = caret + input.selectionEnd = caret + autoSizeAndCenter() + }) + input.onfocus = function (): void { const bRect = blockRect() selectorInstance.update( diff --git a/src/components/sidebar/elementProperties.tsx b/src/components/sidebar/elementProperties.tsx index 8e427a2..cb03252 100644 --- a/src/components/sidebar/elementProperties.tsx +++ b/src/components/sidebar/elementProperties.tsx @@ -377,6 +377,7 @@ const FontFamilyRow = ({ const families = [ { label: 'Caveat', family: 'Caveat' }, { label: 'Geist', family: 'Geist' }, + { label: 'Caveat Brush', family: 'Caveat Brush' }, ] return (
diff --git a/src/constants/misc.ts b/src/constants/misc.ts index 27f0546..5f12ea1 100644 --- a/src/constants/misc.ts +++ b/src/constants/misc.ts @@ -1,5 +1,13 @@ export const offsetHeight = 0 export const GROUP_COMPONENT = 'groupobject' + +// Default canvas text font (single source of truth). Kept in sync with the +// `--font-sketch` / `--font-caveat-brush` vars in App.css, the Tailwind +// `sketch` token, and the Google Fonts in index.html. Every canvas-text +// fallback (`family || DEFAULT_TEXT_FONT_FAMILY`) references this so the default +// lives in exactly one place. Only the Regular 400 weight is loaded/used. +export const DEFAULT_TEXT_FONT_FAMILY = 'Caveat Brush' + export const RUBBER_MODE_KEY = 'rubberMode' export const VIEWPORT_KEY_PREFIX = 'craftbase_viewport_' export const MOBILE_VIEWPORT_KEY_PREFIX = 'craftbase_mobile_viewport_' diff --git a/src/factory/newText.ts b/src/factory/newText.ts index 4e23566..58fba0a 100644 --- a/src/factory/newText.ts +++ b/src/factory/newText.ts @@ -1,4 +1,5 @@ import Main from './main' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' export interface NewTextMetadata { content?: string @@ -23,7 +24,7 @@ export default class NewTextFactory extends Main { const { content = '', fontSize = 36, - textFontFamily = 'Caveat', + textFontFamily = DEFAULT_TEXT_FONT_FAMILY, } = this.properties?.metadata ?? {} // Use native Two.js text instead of a foreignObject wrapper diff --git a/src/hooks/useComponentHistory.ts b/src/hooks/useComponentHistory.ts index 62dfd2c..ad315f9 100644 --- a/src/hooks/useComponentHistory.ts +++ b/src/hooks/useComponentHistory.ts @@ -174,9 +174,18 @@ function applyPropertyToTwoJSGroup( Object.entries(value as Record).forEach( ([k, v]) => { if (k === 'opacity') { - // Opacity lives on the leaf shape (children[0]) by - // codebase convention; matches applyGroupProperty. - shape.opacity = v + // Opacity is applied at the GROUP level (see + // applyProperty and the *-with-text components) so + // the shape + text dim uniformly and repaint + // reliably. Reset the leaf/text so they don't + // compound with the group's opacity. + group.opacity = v + shape.opacity = 1 + if (textNodes.length > 0) { + textNodes.forEach( + (n: ShapeLike) => (n.opacity = 1) + ) + } } else if ( k === 'textFontSize' || k === 'fontSize' diff --git a/src/hooks/useElementDefaults.ts b/src/hooks/useElementDefaults.ts index 6c69572..516fd22 100644 --- a/src/hooks/useElementDefaults.ts +++ b/src/hooks/useElementDefaults.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' const STORAGE_KEY = 'craftbase:elementDefaults' @@ -26,7 +27,7 @@ const INITIAL_DEFAULTS: ElementDefaultsState = { defaultOpacity: 1, defaultTextColor: '#1A1612', defaultTextSize: 'M', - defaultTextFontFamily: 'Caveat', + defaultTextFontFamily: DEFAULT_TEXT_FONT_FAMILY, } function loadFromStorage(): ElementDefaultsState { diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index a2b241a..3315ef6 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -50,6 +50,7 @@ import { GEO_DRAW_PROPS_KEY, GEO_POINT_PLACE_MODE_KEY, GEO_MIN_VERTICES, + DEFAULT_TEXT_FONT_FAMILY, } from './constants/misc' import Spinner from './components/common/spinner' @@ -630,7 +631,7 @@ function addZUI( input.style.padding = `${vertPad}px 8px` input.style.color = textStyle.fill || '#3A342C' input.style.fontSize = `${cssFontSize}px` - input.style.fontFamily = textStyle.family || 'Caveat' + input.style.fontFamily = textStyle.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = String(textStyle.weight ?? 'normal') input.style.lineHeight = `${lineH}px` input.style.letterSpacing = '0px' @@ -685,7 +686,8 @@ function addZUI( measureSpan.style.overflowWrap = 'anywhere' measureSpan.style.width = `${usableScreenW}px` measureSpan.style.fontSize = `${cssFontSize}px` - measureSpan.style.fontFamily = textStyle.family || 'Caveat' + measureSpan.style.fontFamily = + textStyle.family || DEFAULT_TEXT_FONT_FAMILY measureSpan.style.fontWeight = String(textStyle.weight ?? 'normal') measureSpan.style.lineHeight = `${lineH}px` measureSpan.style.letterSpacing = '0px' diff --git a/src/utils/canvasUtils.ts b/src/utils/canvasUtils.ts index 91e8f19..cd9b904 100644 --- a/src/utils/canvasUtils.ts +++ b/src/utils/canvasUtils.ts @@ -1,4 +1,7 @@ -import { SHAPE_DEFAULT_STROKE } from '../constants/misc' +import { + SHAPE_DEFAULT_STROKE, + DEFAULT_TEXT_FONT_FAMILY, +} from '../constants/misc' import { generateUUID } from './misc' import { lineHeightFor, measureTextWidth, type FontSpec } from './textLayout' import { reflowTextForShape } from './shapeTextFit' @@ -368,7 +371,8 @@ export function shapeTextStyleFromMeta(meta: ShapeLike): { style: ShapeTextStyle font: FontSpec } { - const family = meta?.textFontFamily || meta?.textFamily || 'Caveat' + const family = + meta?.textFontFamily || meta?.textFamily || DEFAULT_TEXT_FONT_FAMILY const size = meta?.textFontSize || 24 const weight = meta?.textWeight || 'normal' return { diff --git a/src/utils/exportViewport.ts b/src/utils/exportViewport.ts index 280c14a..e833ebb 100644 --- a/src/utils/exportViewport.ts +++ b/src/utils/exportViewport.ts @@ -10,6 +10,7 @@ // download it as PNG. import { SVG_NS, embedFonts } from './svgExportShared' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' const CANVAS_BG = '#f5f0e8' // --color-canvas (App.css) const DOT_COLOR = '#c4b89a' // radial-gradient dot color (App.css) @@ -98,7 +99,7 @@ function appendWatermark( text.setAttribute('x', String(width - 16)) text.setAttribute('y', String(height - 14)) text.setAttribute('text-anchor', 'end') - text.setAttribute('font-family', 'Caveat') + text.setAttribute('font-family', DEFAULT_TEXT_FONT_FAMILY) text.setAttribute('font-size', '20') text.setAttribute('fill', '#8C7E6A') text.setAttribute('fill-opacity', '0.8') diff --git a/src/utils/htmlToBulletText.ts b/src/utils/htmlToBulletText.ts new file mode 100644 index 0000000..d207ddd --- /dev/null +++ b/src/utils/htmlToBulletText.ts @@ -0,0 +1,125 @@ +// Convert pasted rich-text HTML (e.g. from Docs/Notion/Notes) into plain text +// that keeps list markers, since the canvas text editor is a plain