@@ -516,7 +517,7 @@ const ElementPropertiesToolbar = () => {
const sections = SETS[setKey as keyof typeof SETS]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handle =
- (key: string) =>
+ (key: string, opts?: { preview?: boolean }) =>
(val: any): void => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValues((prev: any) => ({ ...prev, [key]: val }))
@@ -533,13 +534,13 @@ const ElementPropertiesToolbar = () => {
? entry.mobileValue
: entry.value
: val
- applyGroupProperty?.(key, numeric)
+ applyGroupProperty?.(key, numeric, opts)
} else {
- applyGroupProperty?.(key, val)
+ applyGroupProperty?.(key, val, opts)
}
return
}
- applyProperty?.(key, val)
+ applyProperty?.(key, val, opts)
}
return (
@@ -652,11 +653,10 @@ const ElementPropertiesToolbar = () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- setValues((prev: any) => ({
- ...prev,
- opacity: arr[0],
- }))
+ // Live preview while dragging: applies to the scene
+ // only (no store/history write) so the element fades
+ // in real time. The release (handleOnChange) commits.
+ handle('opacity', { preview: true })(arr[0])
}
handleOnChange={(arr) => handle('opacity')(arr[0])}
/>
diff --git a/src/components/sidebar/primary.tsx b/src/components/sidebar/primary.tsx
index b0f4162..85c38b6 100644
--- a/src/components/sidebar/primary.tsx
+++ b/src/components/sidebar/primary.tsx
@@ -59,7 +59,6 @@ const PrimarySidebar = (): ReactElement => {
defaultStrokeType,
defaultFill,
defaultStrokeColor,
- defaultOpacity,
defaultTextFontFamily,
} = useBoardContext()
const [hintText, setHintText] = useState(
@@ -128,7 +127,7 @@ const PrimarySidebar = (): ReactElement => {
createdAt: null,
metadata: {
...((item.metadata as Record) ?? {}),
- opacity: defaultOpacity ?? 1,
+ opacity: 1,
...(defaultTextFontFamily && {
textFontFamily: defaultTextFontFamily,
}),
@@ -167,7 +166,7 @@ const PrimarySidebar = (): ReactElement => {
isDummy: null,
createdAt: null,
metadata: {
- opacity: defaultOpacity ?? 1,
+ opacity: 1,
...(defaultTextFontFamily && {
textFontFamily: defaultTextFontFamily,
}),
@@ -406,7 +405,7 @@ const PrimarySidebar = (): ReactElement => {
string,
unknown
>) ?? {}),
- opacity: defaultOpacity ?? 1,
+ opacity: 1,
...(defaultTextFontFamily && {
textFontFamily: defaultTextFontFamily,
}),
@@ -456,7 +455,7 @@ const PrimarySidebar = (): ReactElement => {
isDummy: null,
createdAt: null,
metadata: {
- opacity: defaultOpacity ?? 1,
+ opacity: 1,
...(defaultTextFontFamily && {
textFontFamily: defaultTextFontFamily,
}),
diff --git a/src/components/utils/colorPicker.tsx b/src/components/utils/colorPicker.tsx
index 06fa0f6..472d4ac 100644
--- a/src/components/utils/colorPicker.tsx
+++ b/src/components/utils/colorPicker.tsx
@@ -135,7 +135,7 @@ const ColorPicker = ({
{title && (
-
+
{title}
)}
diff --git a/src/constants/misc.ts b/src/constants/misc.ts
index 27f0546..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 20724f0..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'
@@ -760,6 +769,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/hooks/useElementDefaults.ts b/src/hooks/useElementDefaults.ts
index 10bf788..516fd22 100644
--- a/src/hooks/useElementDefaults.ts
+++ b/src/hooks/useElementDefaults.ts
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
+import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc'
const STORAGE_KEY = 'craftbase:elementDefaults'
@@ -26,7 +27,7 @@ const INITIAL_DEFAULTS: ElementDefaultsState = {
defaultOpacity: 1,
defaultTextColor: '#1A1612',
defaultTextSize: 'M',
- defaultTextFontFamily: 'Caveat',
+ defaultTextFontFamily: DEFAULT_TEXT_FONT_FAMILY,
}
function loadFromStorage(): ElementDefaultsState {
@@ -59,6 +60,9 @@ export interface ElementDefaultsApi extends ElementDefaultsState {
setDefaultTextColorInBoard: (val: string) => void
setDefaultTextSizeInBoard: (val: TextSizeLabel) => void
setDefaultTextFontFamilyInBoard: (val: string) => void
+ // Restore every default to its factory value (used by "clear canvas" so a
+ // leaked default like linewidth:0 doesn't carry over to the next drawing).
+ resetDefaults: () => void
}
export function useElementDefaults(): ElementDefaultsApi {
@@ -137,6 +141,19 @@ export function useElementDefaults(): ElementDefaultsApi {
const setDefaultTextFontFamilyInBoard = (val: string): void =>
setDefaultTextFontFamily(val)
+ // Reset all defaults to INITIAL_DEFAULTS. The persistence effect above then
+ // rewrites localStorage to the factory values on the resulting state change.
+ const resetDefaults = (): void => {
+ setDefaultFill(INITIAL_DEFAULTS.defaultFill)
+ setDefaultStrokeColor(INITIAL_DEFAULTS.defaultStrokeColor)
+ setDefaultLinewidth(INITIAL_DEFAULTS.defaultLinewidth)
+ setDefaultStrokeType(INITIAL_DEFAULTS.defaultStrokeType)
+ setDefaultOpacity(INITIAL_DEFAULTS.defaultOpacity)
+ setDefaultTextColor(INITIAL_DEFAULTS.defaultTextColor)
+ setDefaultTextSize(INITIAL_DEFAULTS.defaultTextSize)
+ setDefaultTextFontFamily(INITIAL_DEFAULTS.defaultTextFontFamily)
+ }
+
return {
defaultFill,
defaultStrokeColor,
@@ -162,5 +179,6 @@ export function useElementDefaults(): ElementDefaultsApi {
setDefaultTextColorInBoard,
setDefaultTextSizeInBoard,
setDefaultTextFontFamilyInBoard,
+ resetDefaults,
}
}
diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx
index 20866c1..3315ef6 100644
--- a/src/newCanvas.tsx
+++ b/src/newCanvas.tsx
@@ -12,6 +12,7 @@ import React, {
useEffect,
useState,
useRef,
+ useCallback,
Suspense,
type MutableRefObject,
type ReactNode,
@@ -49,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'
@@ -78,6 +80,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,
@@ -627,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'
@@ -682,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'
@@ -2339,7 +2344,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
@@ -2685,6 +2702,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 +3088,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 +3300,17 @@ const Canvas: React.FC = (props) => {
))}
+ {ctxMenu && (
+ setCtxMenu(null)}
+ onExportSvg={() => {
+ setCtxMenu(null)
+ void exportActiveSelection()
+ }}
+ />
+ )}
>
)
}
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..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'
@@ -276,7 +279,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'
)
}
@@ -360,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/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