diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index a930b81ab2a4..35189ec48261 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -15,15 +15,14 @@ runs: with: node-version: 24.13.1 - - name: Restore node_modules cache - uses: actions/cache@v5 - id: node-modules-cache + # Restore-only: PRs read from main's cache but never save their own copy. + # This avoids duplicating ~1GB of yarn cache per PR branch. + - name: Restore yarn cache + uses: actions/cache/restore@v5 + id: yarn-cache with: path: | - node_modules - **/node_modules .yarn/cache - .yarn/unplugged .yarn/install-state.gz key: ${{ runner.os }}-node-24.13.1-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml', '.yarn/patches/*') }} restore-keys: | @@ -36,3 +35,14 @@ runs: # Hardened mode enables --check-resolutions on public PRs, adding ~15s. # See https://yarnpkg.com/features/security - Yarn recommends disabling in most jobs. YARN_ENABLE_HARDENED_MODE: 0 + + # Only save the cache on pushes to main to avoid per-PR duplicates. + # Runs after install so the cache is fully populated. + - name: Save yarn cache + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.yarn-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + .yarn/cache + .yarn/install-state.gz + key: ${{ runner.os }}-node-24.13.1-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml', '.yarn/patches/*') }} diff --git a/apps/examples/package.json b/apps/examples/package.json index 7c56206e989f..355e98380cbb 100644 --- a/apps/examples/package.json +++ b/apps/examples/package.json @@ -54,6 +54,7 @@ "ag-grid-react": "^32.3.3", "axe-core": "^4.10.3", "classnames": "^2.5.1", + "d3-geo": "^3.1.1", "lazyrepo": "0.0.0-alpha.27", "lodash": "^4.17.21", "pdf-lib": "^1.17.1", @@ -64,12 +65,17 @@ "react-helmet-async": "^1.3.0", "react-router-dom": "^6.28.2", "tldraw": "workspace:*", + "topojson-client": "^3.1.0", + "us-atlas": "^3.0.1", "vite": "^7.0.1" }, "devDependencies": { + "@types/d3-geo": "^3.1.0", "@types/lodash": "^4.17.14", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/topojson-client": "^3.1.5", + "@types/topojson-specification": "^1.0.5", "@vitejs/plugin-react-swc": "^3.10.2", "dotenv": "^16.4.7", "remark": "^15.0.1", diff --git a/apps/examples/src/examples/use-cases/d3-map/D3MapExample.tsx b/apps/examples/src/examples/use-cases/d3-map/D3MapExample.tsx new file mode 100644 index 000000000000..06520819e392 --- /dev/null +++ b/apps/examples/src/examples/use-cases/d3-map/D3MapExample.tsx @@ -0,0 +1,78 @@ +import { Editor, TLComponents, Tldraw, useEditor } from 'tldraw' +import 'tldraw/tldraw.css' +import { UsMapShapeUtil } from './UsMapShapeUtil' +import { UsStateShapeUtil } from './UsStateShapeUtil' +import './d3-map.css' +import { MAP_HEIGHT, MAP_WIDTH } from './us-map-data' + +export const STATE_COLORS = [ + '#4e79a7', + '#f28e2b', + '#e15759', + '#76b7b2', + '#59a14f', + '#edc948', + '#b07aa1', + '#ff9da7', + '#9c755f', + '#bab0ac', + '#af7aa1', + '#d4a373', +] + +const shapeUtils = [UsMapShapeUtil, UsStateShapeUtil] + +function resetMap(editor: Editor) { + const allShapeIds = [...editor.getCurrentPageShapeIds()] + const mapAndStateIds = allShapeIds.filter((id) => { + const shape = editor.getShape(id) + return shape?.type === 'us-map' || shape?.type === 'us-state' + }) + if (mapAndStateIds.length > 0) { + editor.deleteShapes(mapAndStateIds) + } + editor.createShape({ + type: 'us-map', + x: 0, + y: 0, + props: { w: MAP_WIDTH, h: MAP_HEIGHT }, + }) + editor.zoomToFit({ animation: { duration: 200 } }) +} + +function TopPanel() { + const editor = useEditor() + return ( +
+ +
+ ) +} + +const components: TLComponents = { + TopPanel, +} + +export default function D3MapExample() { + return ( +
+ { + if (editor.getCurrentPageShapeIds().size === 0) { + editor.createShape({ + type: 'us-map', + x: 0, + y: 0, + props: { w: MAP_WIDTH, h: MAP_HEIGHT }, + }) + } + editor.zoomToFit({ animation: { duration: 0 } }) + }} + /> +
+ ) +} diff --git a/apps/examples/src/examples/use-cases/d3-map/README.md b/apps/examples/src/examples/use-cases/d3-map/README.md new file mode 100644 index 000000000000..46738114274f --- /dev/null +++ b/apps/examples/src/examples/use-cases/d3-map/README.md @@ -0,0 +1,11 @@ +--- +title: D3 geo map +component: ./D3MapExample.tsx +keywords: [d3, geo, map, geography, shapes, states, topojson, visualization] +--- + +A D3 geo map rendered as tldraw shapes that can be exploded into individually manipulable states. + +--- + +This example demonstrates integrating a D3 geographic projection with tldraw's custom shape system. A composite US map shape can be "exploded" into individual state shapes that can be moved, resized, and selected independently. The explode operation is atomic and fully undoable. diff --git a/apps/examples/src/examples/use-cases/d3-map/UsMapShapeUtil.tsx b/apps/examples/src/examples/use-cases/d3-map/UsMapShapeUtil.tsx new file mode 100644 index 000000000000..f2eeccba42f5 --- /dev/null +++ b/apps/examples/src/examples/use-cases/d3-map/UsMapShapeUtil.tsx @@ -0,0 +1,132 @@ +import { + Editor, + Geometry2d, + RecordProps, + Rectangle2d, + SVGContainer, + ShapeUtil, + T, + TLResizeInfo, + TLShape, + createShapeId, + resizeBox, +} from 'tldraw' +import { STATE_COLORS } from './D3MapExample' +import { MAP_HEIGHT, MAP_WIDTH, getUsStatesData } from './us-map-data' + +const US_MAP_TYPE = 'us-map' as const + +declare module 'tldraw' { + export interface TLGlobalShapePropsMap { + [US_MAP_TYPE]: { w: number; h: number } + } +} + +export type UsMapShape = TLShape + +const statesData = getUsStatesData() + +export function explodeMap(editor: Editor, shape: UsMapShape) { + const bounds = editor.getShapePageBounds(shape) + if (!bounds) return + + const scaleX = bounds.w / MAP_WIDTH + const scaleY = bounds.h / MAP_HEIGHT + + editor.run( + () => { + const newShapes = statesData.map((state, i) => ({ + id: createShapeId(), + type: 'us-state' as const, + x: bounds.x + state.bounds.x * scaleX, + y: bounds.y + state.bounds.y * scaleY, + props: { + w: state.bounds.w * scaleX, + h: state.bounds.h * scaleY, + name: state.name, + pathData: state.pathData, + fill: STATE_COLORS[i % STATE_COLORS.length], + originalW: state.bounds.w, + originalH: state.bounds.h, + pathOffsetX: state.bounds.x, + pathOffsetY: state.bounds.y, + }, + })) + + editor.deleteShape(shape.id) + editor.createShapes(newShapes) + }, + { history: 'record-preserveRedoStack' } + ) +} + +export class UsMapShapeUtil extends ShapeUtil { + static override type = US_MAP_TYPE + static override props: RecordProps = { + w: T.number, + h: T.number, + } + + getDefaultProps(): UsMapShape['props'] { + return { w: MAP_WIDTH, h: MAP_HEIGHT } + } + + override canResize() { + return true + } + override isAspectRatioLocked() { + return true + } + + getGeometry(shape: UsMapShape): Geometry2d { + return new Rectangle2d({ + width: shape.props.w, + height: shape.props.h, + isFilled: true, + }) + } + + override onResize(shape: UsMapShape, info: TLResizeInfo) { + return resizeBox(shape, info) + } + + override onDoubleClick(shape: UsMapShape) { + explodeMap(this.editor, shape) + } + + component(shape: UsMapShape) { + const scaleX = shape.props.w / MAP_WIDTH + const scaleY = shape.props.h / MAP_HEIGHT + + return ( + <> + + + {statesData.map((state, i) => ( + + ))} + + + + + ) + } + + indicator(shape: UsMapShape) { + return + } +} diff --git a/apps/examples/src/examples/use-cases/d3-map/UsStateShapeUtil.tsx b/apps/examples/src/examples/use-cases/d3-map/UsStateShapeUtil.tsx new file mode 100644 index 000000000000..5de0829a9dba --- /dev/null +++ b/apps/examples/src/examples/use-cases/d3-map/UsStateShapeUtil.tsx @@ -0,0 +1,112 @@ +import { + Geometry2d, + RecordProps, + Rectangle2d, + SVGContainer, + ShapeUtil, + T, + TLResizeInfo, + TLShape, + resizeBox, +} from 'tldraw' + +const US_STATE_TYPE = 'us-state' as const + +declare module 'tldraw' { + export interface TLGlobalShapePropsMap { + [US_STATE_TYPE]: { + w: number + h: number + name: string + pathData: string + fill: string + originalW: number + originalH: number + pathOffsetX: number + pathOffsetY: number + } + } +} + +export type UsStateShape = TLShape + +export class UsStateShapeUtil extends ShapeUtil { + static override type = US_STATE_TYPE + static override props: RecordProps = { + w: T.number, + h: T.number, + name: T.string, + pathData: T.string, + fill: T.string, + originalW: T.number, + originalH: T.number, + pathOffsetX: T.number, + pathOffsetY: T.number, + } + + getDefaultProps(): UsStateShape['props'] { + return { + w: 100, + h: 100, + name: '', + pathData: '', + fill: '#4e79a7', + originalW: 100, + originalH: 100, + pathOffsetX: 0, + pathOffsetY: 0, + } + } + + override canResize() { + return true + } + override isAspectRatioLocked() { + return true + } + + getGeometry(shape: UsStateShape): Geometry2d { + return new Rectangle2d({ + width: shape.props.w, + height: shape.props.h, + isFilled: true, + }) + } + + override onResize(shape: UsStateShape, info: TLResizeInfo) { + return resizeBox(shape, info) + } + + component(shape: UsStateShape) { + const { w, h, pathData, fill, originalW, originalH, pathOffsetX, pathOffsetY, name } = + shape.props + const scaleX = w / originalW + const scaleY = h / originalH + + return ( + <> + + + + + +
+ {name} +
+ + ) + } + + indicator(shape: UsStateShape) { + return + } +} diff --git a/apps/examples/src/examples/use-cases/d3-map/d3-map.css b/apps/examples/src/examples/use-cases/d3-map/d3-map.css new file mode 100644 index 000000000000..841a523b4713 --- /dev/null +++ b/apps/examples/src/examples/use-cases/d3-map/d3-map.css @@ -0,0 +1,52 @@ +.d3-map-explode-button { + position: absolute; + bottom: calc(8px * var(--tl-scale)); + right: calc(8px * var(--tl-scale)); + padding: calc(6px * var(--tl-scale)) calc(12px * var(--tl-scale)); + font-size: calc(13px * var(--tl-scale)); + background: var(--tl-color-low); + color: var(--tl-color-text); + border: 1px solid var(--tl-color-text-3); + border-radius: calc(var(--tl-radius-4) * var(--tl-scale)); + cursor: pointer; + white-space: nowrap; +} + +.d3-map-explode-button:hover { + background: var(--tl-color-muted-2); +} + +.d3-map-state-label { + position: absolute; + bottom: 0; + left: 0; + padding: calc(2px * var(--tl-scale)) calc(4px * var(--tl-scale)); + color: var(--tl-color-text); + background: var(--tl-color-low); + border-top-right-radius: calc(var(--tl-radius-2) * var(--tl-scale)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.d3-map-top-panel { + display: flex; + gap: 8px; + padding: 4px; + pointer-events: all; +} + +.d3-map-reset-button { + padding: 4px 12px; + font-size: 13px; + background: var(--tl-color-low); + color: var(--tl-color-text); + border: 1px solid var(--tl-color-text-3); + border-radius: var(--tl-radius-4); + cursor: pointer; +} + +.d3-map-reset-button:hover { + background: var(--tl-color-muted-2); +} diff --git a/apps/examples/src/examples/use-cases/d3-map/us-map-data.ts b/apps/examples/src/examples/use-cases/d3-map/us-map-data.ts new file mode 100644 index 000000000000..032858daa978 --- /dev/null +++ b/apps/examples/src/examples/use-cases/d3-map/us-map-data.ts @@ -0,0 +1,99 @@ +import { geoAlbersUsa, geoPath } from 'd3-geo' +import { feature } from 'topojson-client' +import type { GeometryCollection, Topology } from 'topojson-specification' +import usTopology from 'us-atlas/states-10m.json' + +export const MAP_WIDTH = 975 +export const MAP_HEIGHT = 610 + +const projection = geoAlbersUsa() + .scale(1300) + .translate([MAP_WIDTH / 2, MAP_HEIGHT / 2]) +const pathGenerator = geoPath(projection) + +// FIPS code to state name +const FIPS_TO_NAME: Record = { + '01': 'Alabama', + '02': 'Alaska', + '04': 'Arizona', + '05': 'Arkansas', + '06': 'California', + '08': 'Colorado', + '09': 'Connecticut', + '10': 'Delaware', + '11': 'DC', + '12': 'Florida', + '13': 'Georgia', + '15': 'Hawaii', + '16': 'Idaho', + '17': 'Illinois', + '18': 'Indiana', + '19': 'Iowa', + '20': 'Kansas', + '21': 'Kentucky', + '22': 'Louisiana', + '23': 'Maine', + '24': 'Maryland', + '25': 'Massachusetts', + '26': 'Michigan', + '27': 'Minnesota', + '28': 'Mississippi', + '29': 'Missouri', + '30': 'Montana', + '31': 'Nebraska', + '32': 'Nevada', + '33': 'New Hampshire', + '34': 'New Jersey', + '35': 'New Mexico', + '36': 'New York', + '37': 'North Carolina', + '38': 'North Dakota', + '39': 'Ohio', + '40': 'Oklahoma', + '41': 'Oregon', + '42': 'Pennsylvania', + '44': 'Rhode Island', + '45': 'South Carolina', + '46': 'South Dakota', + '47': 'Tennessee', + '48': 'Texas', + '49': 'Utah', + '50': 'Vermont', + '51': 'Virginia', + '53': 'Washington', + '54': 'West Virginia', + '55': 'Wisconsin', + '56': 'Wyoming', +} + +export interface UsStateData { + id: string + name: string + pathData: string + bounds: { x: number; y: number; w: number; h: number } +} + +export function getUsStatesData(): UsStateData[] { + const topology = usTopology as unknown as Topology<{ states: GeometryCollection }> + const states = feature(topology, topology.objects.states) + const result: UsStateData[] = [] + + for (const f of states.features) { + const id = String(f.id) + const pathData = pathGenerator(f) + if (!pathData) continue + + const bounds = pathGenerator.bounds(f) + const [[x0, y0], [x1, y1]] = bounds + const name = FIPS_TO_NAME[id.padStart(2, '0')] ?? `State ${id}` + + result.push({ + id, + name, + pathData, + bounds: { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }, + }) + } + + return result +} diff --git a/assets/translations/main.json b/assets/translations/main.json index 3fc3f8ed50be..4344bef7854c 100644 --- a/assets/translations/main.json +++ b/assets/translations/main.json @@ -18,6 +18,8 @@ "action.back-to-content": "Back to content", "action.bring-forward": "Bring forward", "action.bring-to-front": "Bring to front", + "action.copy-as-json.short": "JSON", + "action.copy-as-json": "Copy as JSON", "action.copy-as-png.short": "PNG", "action.copy-as-png": "Copy as PNG", "action.copy-as-svg.short": "SVG", diff --git a/packages/editor/api-report.api.md b/packages/editor/api-report.api.md index ec9ec1660873..b945fcd93f62 100644 --- a/packages/editor/api-report.api.md +++ b/packages/editor/api-report.api.md @@ -5005,6 +5005,8 @@ export class Vec { // (undocumented) static FromArray(v: number[]): Vec; // (undocumented) + static IsFinite(A: VecLike): boolean; + // (undocumented) static IsNaN(A: VecLike): boolean; // (undocumented) static Len(A: VecLike): number; diff --git a/packages/editor/src/lib/primitives/Vec.ts b/packages/editor/src/lib/primitives/Vec.ts index 28cc96aebfaf..c8e7f925aa6b 100644 --- a/packages/editor/src/lib/primitives/Vec.ts +++ b/packages/editor/src/lib/primitives/Vec.ts @@ -476,6 +476,10 @@ export class Vec { return isNaN(A.x) || isNaN(A.y) } + static IsFinite(A: VecLike): boolean { + return Number.isFinite(A.x) && Number.isFinite(A.y) + } + /** * Get the angle from position A to position B. */ diff --git a/packages/tldraw/api-report.api.md b/packages/tldraw/api-report.api.md index 2182a811b9e0..07e658dbad8c 100644 --- a/packages/tldraw/api-report.api.md +++ b/packages/tldraw/api-report.api.md @@ -576,7 +576,7 @@ export function copyAs(editor: Editor, ids: TLShapeId[], opts: CopyAsOptions): P export function CopyAsMenuGroup(): JSX.Element; // @public (undocumented) -export interface CopyAsOptions extends TLImageExportOptions { +export interface CopyAsOptions extends Omit { format: TLCopyType; } @@ -3265,7 +3265,7 @@ export interface TLComponents extends TLEditorComponents, TLUiComponents { } // @public (undocumented) -export type TLCopyType = 'png' | 'svg'; +export type TLCopyType = 'json' | 'png' | 'svg'; // @public (undocumented) export interface TLDefaultExternalContentHandlerOpts extends TLExternalContentProps { @@ -4934,7 +4934,7 @@ export interface TLUiTranslation { export type TLUiTranslationContextType = TLUiTranslation; // @public (undocumented) -export type TLUiTranslationKey = 'a11y.adjust-shape-styles' | 'a11y.enlarge-shape' | 'a11y.enter-leave-container' | 'a11y.move-shape-faster' | 'a11y.move-shape' | 'a11y.multiple-shapes' | 'a11y.open-context-menu' | 'a11y.open-keyboard-shortcuts' | 'a11y.pan-camera' | 'a11y.repeat-shape' | 'a11y.rotate-shape-ccw-fine' | 'a11y.rotate-shape-ccw' | 'a11y.rotate-shape-cw-fine' | 'a11y.rotate-shape-cw' | 'a11y.select-shape-direction' | 'a11y.select-shape' | 'a11y.shape-image' | 'a11y.shape-index' | 'a11y.shape-video' | 'a11y.shrink-shape' | 'a11y.skip-to-main-content' | 'a11y.status' | 'action.align-bottom' | 'action.align-center-horizontal.short' | 'action.align-center-horizontal' | 'action.align-center-vertical.short' | 'action.align-center-vertical' | 'action.align-left' | 'action.align-right' | 'action.align-top' | 'action.back-to-content' | 'action.bring-forward' | 'action.bring-to-front' | 'action.convert-to-bookmark' | 'action.convert-to-embed' | 'action.copy-as-png.short' | 'action.copy-as-png' | 'action.copy-as-svg.short' | 'action.copy-as-svg' | 'action.copy' | 'action.cut' | 'action.delete' | 'action.distribute-horizontal.short' | 'action.distribute-horizontal' | 'action.distribute-vertical.short' | 'action.distribute-vertical' | 'action.download-original' | 'action.duplicate' | 'action.edit-link' | 'action.enhanced-a11y-mode.menu' | 'action.enhanced-a11y-mode' | 'action.exit-pen-mode' | 'action.export-all-as-png.short' | 'action.export-all-as-png' | 'action.export-all-as-svg.short' | 'action.export-all-as-svg' | 'action.export-as-png.short' | 'action.export-as-png' | 'action.export-as-svg.short' | 'action.export-as-svg' | 'action.fit-frame-to-content' | 'action.flatten-to-image' | 'action.flip-horizontal.short' | 'action.flip-horizontal' | 'action.flip-vertical.short' | 'action.flip-vertical' | 'action.fork-project-on-tldraw' | 'action.fork-project' | 'action.group' | 'action.insert-embed' | 'action.insert-media' | 'action.leave-shared-project' | 'action.new-project' | 'action.new-shared-project' | 'action.open-cursor-chat' | 'action.open-embed-link' | 'action.open-file' | 'action.open-kbd-shortcuts' | 'action.pack' | 'action.paste-error-description' | 'action.paste-error-title' | 'action.paste' | 'action.print' | 'action.redo' | 'action.remove-frame' | 'action.rename' | 'action.rotate-ccw' | 'action.rotate-cw' | 'action.save-copy' | 'action.select-all' | 'action.select-none' | 'action.select-zoom-tool' | 'action.send-backward' | 'action.send-to-back' | 'action.share-project' | 'action.stack-horizontal.short' | 'action.stack-horizontal' | 'action.stack-vertical.short' | 'action.stack-vertical' | 'action.stop-following' | 'action.stretch-horizontal.short' | 'action.stretch-horizontal' | 'action.stretch-vertical.short' | 'action.stretch-vertical' | 'action.toggle-auto-none' | 'action.toggle-auto-pan' | 'action.toggle-auto-size' | 'action.toggle-auto-zoom' | 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode' | 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode' | 'action.toggle-dynamic-size-mode.menu' | 'action.toggle-dynamic-size-mode' | 'action.toggle-edge-scrolling.menu' | 'action.toggle-edge-scrolling' | 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode' | 'action.toggle-grid.menu' | 'action.toggle-grid' | 'action.toggle-invert-zoom.menu' | 'action.toggle-invert-zoom' | 'action.toggle-keyboard-shortcuts.menu' | 'action.toggle-keyboard-shortcuts' | 'action.toggle-lock' | 'action.toggle-mouse' | 'action.toggle-paste-at-cursor.menu' | 'action.toggle-paste-at-cursor' | 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion' | 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode' | 'action.toggle-tool-lock.menu' | 'action.toggle-tool-lock' | 'action.toggle-trackpad' | 'action.toggle-transparent.context-menu' | 'action.toggle-transparent.menu' | 'action.toggle-transparent' | 'action.toggle-wrap-mode.menu' | 'action.toggle-wrap-mode' | 'action.undo' | 'action.ungroup' | 'action.unlock-all' | 'action.zoom-in' | 'action.zoom-out' | 'action.zoom-quick' | 'action.zoom-to-100' | 'action.zoom-to-fit' | 'action.zoom-to-selection' | 'actions-menu.title' | 'align-style.end' | 'align-style.justify' | 'align-style.middle' | 'align-style.start' | 'app.loading' | 'arrow-kind-style.arc' | 'arrow-kind-style.elbow' | 'arrowheadEnd-style.arrow' | 'arrowheadEnd-style.bar' | 'arrowheadEnd-style.diamond' | 'arrowheadEnd-style.dot' | 'arrowheadEnd-style.inverted' | 'arrowheadEnd-style.none' | 'arrowheadEnd-style.pipe' | 'arrowheadEnd-style.square' | 'arrowheadEnd-style.triangle' | 'arrowheadStart-style.arrow' | 'arrowheadStart-style.bar' | 'arrowheadStart-style.diamond' | 'arrowheadStart-style.dot' | 'arrowheadStart-style.inverted' | 'arrowheadStart-style.none' | 'arrowheadStart-style.pipe' | 'arrowheadStart-style.square' | 'arrowheadStart-style.triangle' | 'assets.files.amount-too-many' | 'assets.files.maximum-size' | 'assets.files.size-too-big' | 'assets.files.type-not-allowed' | 'assets.files.upload-failed' | 'assets.url.failed' | 'color-style.black' | 'color-style.blue' | 'color-style.green' | 'color-style.grey' | 'color-style.light-blue' | 'color-style.light-green' | 'color-style.light-red' | 'color-style.light-violet' | 'color-style.orange' | 'color-style.red' | 'color-style.violet' | 'color-style.white' | 'color-style.yellow' | 'context-menu.arrange' | 'context-menu.copy-as' | 'context-menu.edit' | 'context-menu.export-all-as' | 'context-menu.export-as' | 'context-menu.move-to-page' | 'context-menu.reorder' | 'context-menu.title' | 'context.pages.new-page' | 'cursor-chat.type-to-chat' | 'dash-style.dashed' | 'dash-style.dotted' | 'dash-style.draw' | 'dash-style.solid' | 'document-name-menu.copy-link' | 'document.default-name' | 'edit-link-dialog.cancel' | 'edit-link-dialog.clear' | 'edit-link-dialog.detail' | 'edit-link-dialog.external-link' | 'edit-link-dialog.invalid-url' | 'edit-link-dialog.save' | 'edit-link-dialog.title' | 'edit-link-dialog.url' | 'embed-dialog.back' | 'embed-dialog.cancel' | 'embed-dialog.create' | 'embed-dialog.instruction' | 'embed-dialog.invalid-url' | 'embed-dialog.title' | 'embed-dialog.url' | 'file-system.confirm-clear.cancel' | 'file-system.confirm-clear.continue' | 'file-system.confirm-clear.description' | 'file-system.confirm-clear.dont-show-again' | 'file-system.confirm-clear.title' | 'file-system.confirm-open.cancel' | 'file-system.confirm-open.description' | 'file-system.confirm-open.dont-show-again' | 'file-system.confirm-open.open' | 'file-system.confirm-open.title' | 'file-system.file-open-error.file-format-version-too-new' | 'file-system.file-open-error.generic-corrupted-file' | 'file-system.file-open-error.not-a-tldraw-file' | 'file-system.file-open-error.title' | 'file-system.shared-document-file-open-error.description' | 'file-system.shared-document-file-open-error.title' | 'fill-style.fill' | 'fill-style.lined-fill' | 'fill-style.none' | 'fill-style.pattern' | 'fill-style.semi' | 'fill-style.solid' | 'focus-mode.toggle-focus-mode' | 'font-style.draw' | 'font-style.mono' | 'font-style.sans' | 'font-style.serif' | 'geo-style.arrow-down' | 'geo-style.arrow-left' | 'geo-style.arrow-right' | 'geo-style.arrow-up' | 'geo-style.check-box' | 'geo-style.cloud' | 'geo-style.diamond' | 'geo-style.ellipse' | 'geo-style.heart' | 'geo-style.hexagon' | 'geo-style.octagon' | 'geo-style.oval' | 'geo-style.pentagon' | 'geo-style.rectangle' | 'geo-style.rhombus-2' | 'geo-style.rhombus' | 'geo-style.star' | 'geo-style.trapezoid' | 'geo-style.triangle' | 'geo-style.x-box' | 'handle.crop.bottom-left' | 'handle.crop.bottom-right' | 'handle.crop.bottom' | 'handle.crop.left' | 'handle.crop.right' | 'handle.crop.top-left' | 'handle.crop.top-right' | 'handle.crop.top' | 'handle.resize-bottom-left' | 'handle.resize-bottom-right' | 'handle.resize-bottom' | 'handle.resize-left' | 'handle.resize-right' | 'handle.resize-top-left' | 'handle.resize-top-right' | 'handle.resize-top' | 'handle.rotate.bottom_left_rotate' | 'handle.rotate.bottom_right_rotate' | 'handle.rotate.mobile_rotate' | 'handle.rotate.top_left_rotate' | 'handle.rotate.top_right_rotate' | 'help-menu.about' | 'help-menu.discord' | 'help-menu.github' | 'help-menu.import-tldr-file' | 'help-menu.keyboard-shortcuts' | 'help-menu.privacy' | 'help-menu.terms' | 'help-menu.title' | 'help-menu.twitter' | 'menu.accessibility' | 'menu.copy-as' | 'menu.edit' | 'menu.export-as' | 'menu.file' | 'menu.input-device' | 'menu.language' | 'menu.preferences' | 'menu.theme' | 'menu.title' | 'menu.view' | 'navigation-zone.minimap' | 'navigation-zone.title' | 'navigation-zone.toggle-minimap' | 'navigation-zone.zoom' | 'opacity-style.0.1' | 'opacity-style.0.25' | 'opacity-style.0.5' | 'opacity-style.0.75' | 'opacity-style.1' | 'page-menu.create-new-page' | 'page-menu.edit-done' | 'page-menu.edit-start' | 'page-menu.go-to-page' | 'page-menu.max-page-count-reached' | 'page-menu.new-page-initial-name' | 'page-menu.submenu.delete' | 'page-menu.submenu.duplicate-page' | 'page-menu.submenu.move-down' | 'page-menu.submenu.move-up' | 'page-menu.submenu.rename' | 'page-menu.submenu.title' | 'page-menu.title' | 'people-menu.anonymous-user' | 'people-menu.avatar-color' | 'people-menu.change-color' | 'people-menu.change-name' | 'people-menu.follow' | 'people-menu.following' | 'people-menu.invite' | 'people-menu.leading' | 'people-menu.title' | 'people-menu.user' | 'share-menu.copied' | 'share-menu.copy-link-note' | 'share-menu.copy-link' | 'share-menu.copy-readonly-link-note' | 'share-menu.copy-readonly-link' | 'share-menu.create-snapshot-link' | 'share-menu.creating-project' | 'share-menu.fork-note' | 'share-menu.offline-note' | 'share-menu.project-too-large' | 'share-menu.save-note' | 'share-menu.share-project' | 'share-menu.snapshot-link-note' | 'share-menu.title' | 'share-menu.upload-failed' | 'sharing.confirm-leave.cancel' | 'sharing.confirm-leave.description' | 'sharing.confirm-leave.dont-show-again' | 'sharing.confirm-leave.leave' | 'sharing.confirm-leave.title' | 'shortcuts-dialog.a11y' | 'shortcuts-dialog.collaboration' | 'shortcuts-dialog.edit' | 'shortcuts-dialog.file' | 'shortcuts-dialog.preferences' | 'shortcuts-dialog.text-formatting' | 'shortcuts-dialog.title' | 'shortcuts-dialog.tools' | 'shortcuts-dialog.transform' | 'shortcuts-dialog.view' | 'size-style.l' | 'size-style.m' | 'size-style.s' | 'size-style.xl' | 'spline-style.cubic' | 'spline-style.line' | 'status.offline' | 'style-panel.align' | 'style-panel.arrow-kind' | 'style-panel.arrowhead-end' | 'style-panel.arrowhead-start' | 'style-panel.arrowheads' | 'style-panel.color' | 'style-panel.dash' | 'style-panel.fill' | 'style-panel.font' | 'style-panel.geo' | 'style-panel.label-align' | 'style-panel.mixed' | 'style-panel.opacity' | 'style-panel.position' | 'style-panel.selected' | 'style-panel.size' | 'style-panel.spline' | 'style-panel.title' | 'style-panel.vertical-align' | 'theme.dark' | 'theme.light' | 'theme.system' | 'toast.close' | 'toast.error.copy-fail.desc' | 'toast.error.copy-fail.title' | 'toast.error.export-fail.desc' | 'toast.error.export-fail.title' | 'toast.error' | 'toast.info' | 'toast.success' | 'toast.warning' | 'tool-panel.more' | 'tool-panel.title' | 'tool.arrow-down' | 'tool.arrow-left' | 'tool.arrow-right' | 'tool.arrow-up' | 'tool.arrow' | 'tool.aspect-ratio.circle' | 'tool.aspect-ratio.landscape' | 'tool.aspect-ratio.original' | 'tool.aspect-ratio.portrait' | 'tool.aspect-ratio.square' | 'tool.aspect-ratio.wide' | 'tool.aspect-ratio' | 'tool.bookmark' | 'tool.check-box' | 'tool.cloud' | 'tool.diamond' | 'tool.draw' | 'tool.ellipse' | 'tool.embed' | 'tool.eraser' | 'tool.flip-horz' | 'tool.flip-vert' | 'tool.frame' | 'tool.hand' | 'tool.heart' | 'tool.hexagon' | 'tool.highlight' | 'tool.image-crop-confirm' | 'tool.image-crop' | 'tool.image-toolbar-title' | 'tool.image-zoom' | 'tool.laser' | 'tool.line' | 'tool.media-alt-text-confirm' | 'tool.media-alt-text-desc' | 'tool.media-alt-text' | 'tool.media' | 'tool.note' | 'tool.octagon' | 'tool.oval' | 'tool.pentagon' | 'tool.pointer-down' | 'tool.rectangle' | 'tool.replace-media' | 'tool.rhombus' | 'tool.rich-text-bold' | 'tool.rich-text-bulletList' | 'tool.rich-text-code' | 'tool.rich-text-header' | 'tool.rich-text-highlight' | 'tool.rich-text-italic' | 'tool.rich-text-link-remove' | 'tool.rich-text-link-visit' | 'tool.rich-text-link' | 'tool.rich-text-orderedList' | 'tool.rich-text-strikethrough' | 'tool.rich-text-toolbar-title' | 'tool.rotate-cw' | 'tool.select' | 'tool.star' | 'tool.text' | 'tool.trapezoid' | 'tool.triangle' | 'tool.x-box' | 'ui.checked' | 'ui.close' | 'ui.unchecked' | 'verticalAlign-style.end' | 'verticalAlign-style.middle' | 'verticalAlign-style.start' | 'vscode.file-open.backup-failed' | 'vscode.file-open.backup-saved' | 'vscode.file-open.backup' | 'vscode.file-open.desc' | 'vscode.file-open.dont-show-again' | 'vscode.file-open.open'; +export type TLUiTranslationKey = 'a11y.adjust-shape-styles' | 'a11y.enlarge-shape' | 'a11y.enter-leave-container' | 'a11y.move-shape-faster' | 'a11y.move-shape' | 'a11y.multiple-shapes' | 'a11y.open-context-menu' | 'a11y.open-keyboard-shortcuts' | 'a11y.pan-camera' | 'a11y.repeat-shape' | 'a11y.rotate-shape-ccw-fine' | 'a11y.rotate-shape-ccw' | 'a11y.rotate-shape-cw-fine' | 'a11y.rotate-shape-cw' | 'a11y.select-shape-direction' | 'a11y.select-shape' | 'a11y.shape-image' | 'a11y.shape-index' | 'a11y.shape-video' | 'a11y.shrink-shape' | 'a11y.skip-to-main-content' | 'a11y.status' | 'action.align-bottom' | 'action.align-center-horizontal.short' | 'action.align-center-horizontal' | 'action.align-center-vertical.short' | 'action.align-center-vertical' | 'action.align-left' | 'action.align-right' | 'action.align-top' | 'action.back-to-content' | 'action.bring-forward' | 'action.bring-to-front' | 'action.convert-to-bookmark' | 'action.convert-to-embed' | 'action.copy-as-json.short' | 'action.copy-as-json' | 'action.copy-as-png.short' | 'action.copy-as-png' | 'action.copy-as-svg.short' | 'action.copy-as-svg' | 'action.copy' | 'action.cut' | 'action.delete' | 'action.distribute-horizontal.short' | 'action.distribute-horizontal' | 'action.distribute-vertical.short' | 'action.distribute-vertical' | 'action.download-original' | 'action.duplicate' | 'action.edit-link' | 'action.enhanced-a11y-mode.menu' | 'action.enhanced-a11y-mode' | 'action.exit-pen-mode' | 'action.export-all-as-png.short' | 'action.export-all-as-png' | 'action.export-all-as-svg.short' | 'action.export-all-as-svg' | 'action.export-as-png.short' | 'action.export-as-png' | 'action.export-as-svg.short' | 'action.export-as-svg' | 'action.fit-frame-to-content' | 'action.flatten-to-image' | 'action.flip-horizontal.short' | 'action.flip-horizontal' | 'action.flip-vertical.short' | 'action.flip-vertical' | 'action.fork-project-on-tldraw' | 'action.fork-project' | 'action.group' | 'action.insert-embed' | 'action.insert-media' | 'action.leave-shared-project' | 'action.new-project' | 'action.new-shared-project' | 'action.open-cursor-chat' | 'action.open-embed-link' | 'action.open-file' | 'action.open-kbd-shortcuts' | 'action.pack' | 'action.paste-error-description' | 'action.paste-error-title' | 'action.paste' | 'action.print' | 'action.redo' | 'action.remove-frame' | 'action.rename' | 'action.rotate-ccw' | 'action.rotate-cw' | 'action.save-copy' | 'action.select-all' | 'action.select-none' | 'action.select-zoom-tool' | 'action.send-backward' | 'action.send-to-back' | 'action.share-project' | 'action.stack-horizontal.short' | 'action.stack-horizontal' | 'action.stack-vertical.short' | 'action.stack-vertical' | 'action.stop-following' | 'action.stretch-horizontal.short' | 'action.stretch-horizontal' | 'action.stretch-vertical.short' | 'action.stretch-vertical' | 'action.toggle-auto-none' | 'action.toggle-auto-pan' | 'action.toggle-auto-size' | 'action.toggle-auto-zoom' | 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode' | 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode' | 'action.toggle-dynamic-size-mode.menu' | 'action.toggle-dynamic-size-mode' | 'action.toggle-edge-scrolling.menu' | 'action.toggle-edge-scrolling' | 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode' | 'action.toggle-grid.menu' | 'action.toggle-grid' | 'action.toggle-invert-zoom.menu' | 'action.toggle-invert-zoom' | 'action.toggle-keyboard-shortcuts.menu' | 'action.toggle-keyboard-shortcuts' | 'action.toggle-lock' | 'action.toggle-mouse' | 'action.toggle-paste-at-cursor.menu' | 'action.toggle-paste-at-cursor' | 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion' | 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode' | 'action.toggle-tool-lock.menu' | 'action.toggle-tool-lock' | 'action.toggle-trackpad' | 'action.toggle-transparent.context-menu' | 'action.toggle-transparent.menu' | 'action.toggle-transparent' | 'action.toggle-wrap-mode.menu' | 'action.toggle-wrap-mode' | 'action.undo' | 'action.ungroup' | 'action.unlock-all' | 'action.zoom-in' | 'action.zoom-out' | 'action.zoom-quick' | 'action.zoom-to-100' | 'action.zoom-to-fit' | 'action.zoom-to-selection' | 'actions-menu.title' | 'align-style.end' | 'align-style.justify' | 'align-style.middle' | 'align-style.start' | 'app.loading' | 'arrow-kind-style.arc' | 'arrow-kind-style.elbow' | 'arrowheadEnd-style.arrow' | 'arrowheadEnd-style.bar' | 'arrowheadEnd-style.diamond' | 'arrowheadEnd-style.dot' | 'arrowheadEnd-style.inverted' | 'arrowheadEnd-style.none' | 'arrowheadEnd-style.pipe' | 'arrowheadEnd-style.square' | 'arrowheadEnd-style.triangle' | 'arrowheadStart-style.arrow' | 'arrowheadStart-style.bar' | 'arrowheadStart-style.diamond' | 'arrowheadStart-style.dot' | 'arrowheadStart-style.inverted' | 'arrowheadStart-style.none' | 'arrowheadStart-style.pipe' | 'arrowheadStart-style.square' | 'arrowheadStart-style.triangle' | 'assets.files.amount-too-many' | 'assets.files.maximum-size' | 'assets.files.size-too-big' | 'assets.files.type-not-allowed' | 'assets.files.upload-failed' | 'assets.url.failed' | 'color-style.black' | 'color-style.blue' | 'color-style.green' | 'color-style.grey' | 'color-style.light-blue' | 'color-style.light-green' | 'color-style.light-red' | 'color-style.light-violet' | 'color-style.orange' | 'color-style.red' | 'color-style.violet' | 'color-style.white' | 'color-style.yellow' | 'context-menu.arrange' | 'context-menu.copy-as' | 'context-menu.edit' | 'context-menu.export-all-as' | 'context-menu.export-as' | 'context-menu.move-to-page' | 'context-menu.reorder' | 'context-menu.title' | 'context.pages.new-page' | 'cursor-chat.type-to-chat' | 'dash-style.dashed' | 'dash-style.dotted' | 'dash-style.draw' | 'dash-style.solid' | 'document-name-menu.copy-link' | 'document.default-name' | 'edit-link-dialog.cancel' | 'edit-link-dialog.clear' | 'edit-link-dialog.detail' | 'edit-link-dialog.external-link' | 'edit-link-dialog.invalid-url' | 'edit-link-dialog.save' | 'edit-link-dialog.title' | 'edit-link-dialog.url' | 'embed-dialog.back' | 'embed-dialog.cancel' | 'embed-dialog.create' | 'embed-dialog.instruction' | 'embed-dialog.invalid-url' | 'embed-dialog.title' | 'embed-dialog.url' | 'file-system.confirm-clear.cancel' | 'file-system.confirm-clear.continue' | 'file-system.confirm-clear.description' | 'file-system.confirm-clear.dont-show-again' | 'file-system.confirm-clear.title' | 'file-system.confirm-open.cancel' | 'file-system.confirm-open.description' | 'file-system.confirm-open.dont-show-again' | 'file-system.confirm-open.open' | 'file-system.confirm-open.title' | 'file-system.file-open-error.file-format-version-too-new' | 'file-system.file-open-error.generic-corrupted-file' | 'file-system.file-open-error.not-a-tldraw-file' | 'file-system.file-open-error.title' | 'file-system.shared-document-file-open-error.description' | 'file-system.shared-document-file-open-error.title' | 'fill-style.fill' | 'fill-style.lined-fill' | 'fill-style.none' | 'fill-style.pattern' | 'fill-style.semi' | 'fill-style.solid' | 'focus-mode.toggle-focus-mode' | 'font-style.draw' | 'font-style.mono' | 'font-style.sans' | 'font-style.serif' | 'geo-style.arrow-down' | 'geo-style.arrow-left' | 'geo-style.arrow-right' | 'geo-style.arrow-up' | 'geo-style.check-box' | 'geo-style.cloud' | 'geo-style.diamond' | 'geo-style.ellipse' | 'geo-style.heart' | 'geo-style.hexagon' | 'geo-style.octagon' | 'geo-style.oval' | 'geo-style.pentagon' | 'geo-style.rectangle' | 'geo-style.rhombus-2' | 'geo-style.rhombus' | 'geo-style.star' | 'geo-style.trapezoid' | 'geo-style.triangle' | 'geo-style.x-box' | 'handle.crop.bottom-left' | 'handle.crop.bottom-right' | 'handle.crop.bottom' | 'handle.crop.left' | 'handle.crop.right' | 'handle.crop.top-left' | 'handle.crop.top-right' | 'handle.crop.top' | 'handle.resize-bottom-left' | 'handle.resize-bottom-right' | 'handle.resize-bottom' | 'handle.resize-left' | 'handle.resize-right' | 'handle.resize-top-left' | 'handle.resize-top-right' | 'handle.resize-top' | 'handle.rotate.bottom_left_rotate' | 'handle.rotate.bottom_right_rotate' | 'handle.rotate.mobile_rotate' | 'handle.rotate.top_left_rotate' | 'handle.rotate.top_right_rotate' | 'help-menu.about' | 'help-menu.discord' | 'help-menu.github' | 'help-menu.import-tldr-file' | 'help-menu.keyboard-shortcuts' | 'help-menu.privacy' | 'help-menu.terms' | 'help-menu.title' | 'help-menu.twitter' | 'menu.accessibility' | 'menu.copy-as' | 'menu.edit' | 'menu.export-as' | 'menu.file' | 'menu.input-device' | 'menu.language' | 'menu.preferences' | 'menu.theme' | 'menu.title' | 'menu.view' | 'navigation-zone.minimap' | 'navigation-zone.title' | 'navigation-zone.toggle-minimap' | 'navigation-zone.zoom' | 'opacity-style.0.1' | 'opacity-style.0.25' | 'opacity-style.0.5' | 'opacity-style.0.75' | 'opacity-style.1' | 'page-menu.create-new-page' | 'page-menu.edit-done' | 'page-menu.edit-start' | 'page-menu.go-to-page' | 'page-menu.max-page-count-reached' | 'page-menu.new-page-initial-name' | 'page-menu.submenu.delete' | 'page-menu.submenu.duplicate-page' | 'page-menu.submenu.move-down' | 'page-menu.submenu.move-up' | 'page-menu.submenu.rename' | 'page-menu.submenu.title' | 'page-menu.title' | 'people-menu.anonymous-user' | 'people-menu.avatar-color' | 'people-menu.change-color' | 'people-menu.change-name' | 'people-menu.follow' | 'people-menu.following' | 'people-menu.invite' | 'people-menu.leading' | 'people-menu.title' | 'people-menu.user' | 'share-menu.copied' | 'share-menu.copy-link-note' | 'share-menu.copy-link' | 'share-menu.copy-readonly-link-note' | 'share-menu.copy-readonly-link' | 'share-menu.create-snapshot-link' | 'share-menu.creating-project' | 'share-menu.fork-note' | 'share-menu.offline-note' | 'share-menu.project-too-large' | 'share-menu.save-note' | 'share-menu.share-project' | 'share-menu.snapshot-link-note' | 'share-menu.title' | 'share-menu.upload-failed' | 'sharing.confirm-leave.cancel' | 'sharing.confirm-leave.description' | 'sharing.confirm-leave.dont-show-again' | 'sharing.confirm-leave.leave' | 'sharing.confirm-leave.title' | 'shortcuts-dialog.a11y' | 'shortcuts-dialog.collaboration' | 'shortcuts-dialog.edit' | 'shortcuts-dialog.file' | 'shortcuts-dialog.preferences' | 'shortcuts-dialog.text-formatting' | 'shortcuts-dialog.title' | 'shortcuts-dialog.tools' | 'shortcuts-dialog.transform' | 'shortcuts-dialog.view' | 'size-style.l' | 'size-style.m' | 'size-style.s' | 'size-style.xl' | 'spline-style.cubic' | 'spline-style.line' | 'status.offline' | 'style-panel.align' | 'style-panel.arrow-kind' | 'style-panel.arrowhead-end' | 'style-panel.arrowhead-start' | 'style-panel.arrowheads' | 'style-panel.color' | 'style-panel.dash' | 'style-panel.fill' | 'style-panel.font' | 'style-panel.geo' | 'style-panel.label-align' | 'style-panel.mixed' | 'style-panel.opacity' | 'style-panel.position' | 'style-panel.selected' | 'style-panel.size' | 'style-panel.spline' | 'style-panel.title' | 'style-panel.vertical-align' | 'theme.dark' | 'theme.light' | 'theme.system' | 'toast.close' | 'toast.error.copy-fail.desc' | 'toast.error.copy-fail.title' | 'toast.error.export-fail.desc' | 'toast.error.export-fail.title' | 'toast.error' | 'toast.info' | 'toast.success' | 'toast.warning' | 'tool-panel.more' | 'tool-panel.title' | 'tool.arrow-down' | 'tool.arrow-left' | 'tool.arrow-right' | 'tool.arrow-up' | 'tool.arrow' | 'tool.aspect-ratio.circle' | 'tool.aspect-ratio.landscape' | 'tool.aspect-ratio.original' | 'tool.aspect-ratio.portrait' | 'tool.aspect-ratio.square' | 'tool.aspect-ratio.wide' | 'tool.aspect-ratio' | 'tool.bookmark' | 'tool.check-box' | 'tool.cloud' | 'tool.diamond' | 'tool.draw' | 'tool.ellipse' | 'tool.embed' | 'tool.eraser' | 'tool.flip-horz' | 'tool.flip-vert' | 'tool.frame' | 'tool.hand' | 'tool.heart' | 'tool.hexagon' | 'tool.highlight' | 'tool.image-crop-confirm' | 'tool.image-crop' | 'tool.image-toolbar-title' | 'tool.image-zoom' | 'tool.laser' | 'tool.line' | 'tool.media-alt-text-confirm' | 'tool.media-alt-text-desc' | 'tool.media-alt-text' | 'tool.media' | 'tool.note' | 'tool.octagon' | 'tool.oval' | 'tool.pentagon' | 'tool.pointer-down' | 'tool.rectangle' | 'tool.replace-media' | 'tool.rhombus' | 'tool.rich-text-bold' | 'tool.rich-text-bulletList' | 'tool.rich-text-code' | 'tool.rich-text-header' | 'tool.rich-text-highlight' | 'tool.rich-text-italic' | 'tool.rich-text-link-remove' | 'tool.rich-text-link-visit' | 'tool.rich-text-link' | 'tool.rich-text-orderedList' | 'tool.rich-text-strikethrough' | 'tool.rich-text-toolbar-title' | 'tool.rotate-cw' | 'tool.select' | 'tool.star' | 'tool.text' | 'tool.trapezoid' | 'tool.triangle' | 'tool.x-box' | 'ui.checked' | 'ui.close' | 'ui.unchecked' | 'verticalAlign-style.end' | 'verticalAlign-style.middle' | 'verticalAlign-style.start' | 'vscode.file-open.backup-failed' | 'vscode.file-open.backup-saved' | 'vscode.file-open.backup' | 'vscode.file-open.desc' | 'vscode.file-open.dont-show-again' | 'vscode.file-open.open'; // @public (undocumented) export interface TLUiTranslationProviderProps { diff --git a/packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts b/packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts index 4311b0ecfe81..d5d3a8523b66 100644 --- a/packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts +++ b/packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts @@ -17,7 +17,6 @@ import { approximately, arrowBindingMigrations, arrowBindingProps, - assert, getIndexAbove, getIndexBetween, intersectLineSegmentCircle, @@ -235,8 +234,14 @@ export function updateArrowTerminal({ throw new Error('expected arrow info') } - const startPoint = useHandle ? info.start.handle : info.start.point - const endPoint = useHandle ? info.end.handle : info.end.point + const startPoint = getValidTerminalPoint( + useHandle ? info.start.handle : info.start.point, + arrow.props.start + ) + const endPoint = getValidTerminalPoint( + useHandle ? info.end.handle : info.end.point, + arrow.props.end + ) const point = terminal === 'start' ? startPoint : endPoint const update = { @@ -251,30 +256,58 @@ export function updateArrowTerminal({ // fix up the bend: if (info.type === 'arc') { // find the new start/end points of the resulting arrow - const newStart = terminal === 'start' ? startPoint : info.start.handle - const newEnd = terminal === 'end' ? endPoint : info.end.handle + const newStart = + terminal === 'start' + ? startPoint + : getValidTerminalPoint(info.start.handle, arrow.props.start) + const newEnd = + terminal === 'end' ? endPoint : getValidTerminalPoint(info.end.handle, arrow.props.end) const newMidPoint = Vec.Med(newStart, newEnd) + const arrowDirection = Vec.Sub(newStart, newEnd) + if (approximately(Vec.Len2(arrowDirection), 0)) { + editor.updateShape(update) + if (unbind) { + removeArrowBinding(editor, arrow, terminal) + } + return + } // intersect a line segment perpendicular to the new arrow with the old arrow arc to // find the new mid-point - const lineSegment = Vec.Sub(newStart, newEnd) + const lineSegment = arrowDirection .per() .uni() .mul(info.handleArc.radius * 2 * Math.sign(arrow.props.bend)) + const targetPoint = Vec.Add(newMidPoint, lineSegment) + if ( + !Vec.IsFinite(info.handleArc.center) || + !Number.isFinite(info.handleArc.radius) || + !Vec.IsFinite(targetPoint) + ) { + editor.updateShape(update) + if (unbind) { + removeArrowBinding(editor, arrow, terminal) + } + return + } // find the intersections with the old arrow arc: const intersections = intersectLineSegmentCircle( info.handleArc.center, - Vec.Add(newMidPoint, lineSegment), + targetPoint, info.handleArc.center, info.handleArc.radius ) - assert(intersections?.length === 1) - const bend = Vec.Dist(newMidPoint, intersections[0]) * Math.sign(arrow.props.bend) - // use `approximately` to avoid endless update loops - if (!approximately(bend, update.props.bend)) { - update.props.bend = bend + if (intersections?.length) { + const intersection = intersections.reduce((closest, candidate) => + Vec.Dist2(candidate, targetPoint) < Vec.Dist2(closest, targetPoint) ? candidate : closest + ) + const bend = Vec.Dist(newMidPoint, intersection) * Math.sign(arrow.props.bend) + // use `approximately` to avoid endless update loops + if (!approximately(bend, update.props.bend)) { + update.props.bend = bend + } } } @@ -283,3 +316,10 @@ export function updateArrowTerminal({ removeArrowBinding(editor, arrow, terminal) } } + +function getValidTerminalPoint( + point: { x: number; y: number }, + fallback: { x: number; y: number } +) { + return Vec.From(Vec.IsFinite(point) ? point : fallback) +} diff --git a/packages/tldraw/src/lib/shapes/shared/useEditablePlainText.ts b/packages/tldraw/src/lib/shapes/shared/useEditablePlainText.ts index 1ff45b3c56de..d6d92d9b33a5 100644 --- a/packages/tldraw/src/lib/shapes/shared/useEditablePlainText.ts +++ b/packages/tldraw/src/lib/shapes/shared/useEditablePlainText.ts @@ -152,7 +152,13 @@ export function useEditableTextCommon(shapeId: TLShapeId) { const html = e.clipboardData.getData('text/html') if (html) { if (html.includes('
editor.getInstanceState().isDebugMode, [editor]) return ( )} + {isDebugMode && } diff --git a/packages/tldraw/src/lib/ui/context/actions.tsx b/packages/tldraw/src/lib/ui/context/actions.tsx index b2bf6b1e6f54..d15cb4c6a4e7 100644 --- a/packages/tldraw/src/lib/ui/context/actions.tsx +++ b/packages/tldraw/src/lib/ui/context/actions.tsx @@ -312,6 +312,22 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { helpers.copyAs(ids, 'png') }, }, + { + id: 'copy-as-json', + label: { + default: 'action.copy-as-json', + menu: 'action.copy-as-json.short', + ['context-menu']: 'action.copy-as-json.short', + }, + readonlyOk: true, + onSelect(source) { + let ids = editor.getSelectedShapeIds() + if (ids.length === 0) ids = Array.from(editor.getCurrentPageShapeIds().values()) + if (ids.length === 0) return + trackEvent('copy-as', { format: 'json', source }) + helpers.copyAs(ids, 'json') + }, + }, { id: 'toggle-auto-size', label: 'action.toggle-auto-size', diff --git a/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts b/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts index 6559b1425022..eba70694f1d6 100644 --- a/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts +++ b/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts @@ -22,6 +22,8 @@ export type TLUiTranslationKey = | 'action.back-to-content' | 'action.bring-forward' | 'action.bring-to-front' + | 'action.copy-as-json.short' + | 'action.copy-as-json' | 'action.copy-as-png.short' | 'action.copy-as-png' | 'action.copy-as-svg.short' diff --git a/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts b/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts index d4022b215394..0a2ba04447fa 100644 --- a/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts +++ b/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts @@ -22,6 +22,8 @@ export const DEFAULT_TRANSLATION = { 'action.back-to-content': 'Back to content', 'action.bring-forward': 'Bring forward', 'action.bring-to-front': 'Bring to front', + 'action.copy-as-json.short': 'JSON', + 'action.copy-as-json': 'Copy as JSON', 'action.copy-as-png.short': 'PNG', 'action.copy-as-png': 'Copy as PNG', 'action.copy-as-svg.short': 'SVG', diff --git a/packages/tldraw/src/lib/utils/export/copyAs.ts b/packages/tldraw/src/lib/utils/export/copyAs.ts index 682d9650c400..47cd784d9066 100644 --- a/packages/tldraw/src/lib/utils/export/copyAs.ts +++ b/packages/tldraw/src/lib/utils/export/copyAs.ts @@ -13,10 +13,10 @@ import { import { exportToImagePromiseForClipboard } from './export' /** @public */ -export type TLCopyType = 'svg' | 'png' +export type TLCopyType = 'svg' | 'png' | 'json' /** @public */ -export interface CopyAsOptions extends TLImageExportOptions { +export interface CopyAsOptions extends Omit { /** The format to copy as. */ format: TLCopyType } @@ -38,8 +38,20 @@ export function copyAs(editor: Editor, ids: TLShapeId[], opts: CopyAsOptions): P // https://bugs.webkit.org/show_bug.cgi?id=222262 if (!navigator.clipboard) return Promise.reject(new Error('Copy not supported')) + + if (opts.format === 'json') { + const content = editor.getContentFromCurrentPage(ids) + const jsonStr = JSON.stringify(content, null, '\t') + return navigator.clipboard.writeText(jsonStr) + } + + const imageOpts: TLImageExportOptions = { + ...opts, + format: opts.format as TLImageExportOptions['format'], + } + if (navigator.clipboard.write as any) { - const { blobPromise, mimeType } = exportToImagePromiseForClipboard(editor, ids, opts) + const { blobPromise, mimeType } = exportToImagePromiseForClipboard(editor, ids, imageOpts) const types: Record> = { [mimeType]: blobPromise } const additionalMimeType = getAdditionalClipboardWriteType(opts.format) @@ -55,7 +67,7 @@ export function copyAs(editor: Editor, ids: TLShapeId[], opts: CopyAsOptions): P switch (opts.format) { case 'svg': { return fallbackWriteTextAsync(async () => { - const result = await editor.getSvgString(ids, opts) + const result = await editor.getSvgString(ids, imageOpts) if (!result) throw new Error('Failed to copy') return result.svg diff --git a/packages/tldraw/src/test/commands/deleteShapes.test.ts b/packages/tldraw/src/test/commands/deleteShapes.test.ts index 7f53679eb269..928dffb6223a 100644 --- a/packages/tldraw/src/test/commands/deleteShapes.test.ts +++ b/packages/tldraw/src/test/commands/deleteShapes.test.ts @@ -103,6 +103,57 @@ describe('Editor.deleteShapes', () => { expect(editor.getShape(ids.box3)).toBeUndefined() expect(editor.getShape(ids.box4)).toBeUndefined() }) + + it('does not crash when deleting a bent arrow and two adjacent bound shapes', () => { + const leftId = createShapeId('left') + const rightId = createShapeId('right') + const bentArrowId = createShapeId('bent-arrow') + + editor.createShapes([ + { id: leftId, type: 'geo', x: 500, y: 500, props: { w: 100, h: 100 } }, + { id: rightId, type: 'geo', x: 600, y: 500, props: { w: 100, h: 100 } }, + { + id: bentArrowId, + type: 'arrow', + x: 550, + y: 550, + props: { + start: { x: 0, y: 0 }, + end: { x: 100, y: 0 }, + bend: -120, + }, + }, + ]) + + editor.createBindings([ + { + id: createBindingId(), + fromId: bentArrowId, + toId: leftId, + type: 'arrow', + props: { + terminal: 'start', + isExact: false, + normalizedAnchor: { x: 1, y: 0.5 }, + isPrecise: false, + }, + }, + { + id: createBindingId(), + fromId: bentArrowId, + toId: rightId, + type: 'arrow', + props: { + terminal: 'end', + isExact: false, + normalizedAnchor: { x: 0, y: 0.5 }, + isPrecise: false, + }, + }, + ]) + + expect(() => editor.deleteShapes([leftId, bentArrowId, rightId])).not.toThrow() + }) }) describe('When deleting arrows', () => { diff --git a/yarn.lock b/yarn.lock index 448f69a12f56..890ae5d4a092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11100,6 +11100,15 @@ __metadata: languageName: node linkType: hard +"@types/d3-geo@npm:^3.1.0": + version: 3.1.0 + resolution: "@types/d3-geo@npm:3.1.0" + dependencies: + "@types/geojson": "npm:*" + checksum: 10/e759d98470fe605ff0088247af81c3197cefce72b16eafe8acae606216c3e0a9f908df4e7cd5005ecfe13b8ac8396a51aaa0d282f3ca7d1c3850313a13fac905 + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -11200,6 +11209,13 @@ __metadata: languageName: node linkType: hard +"@types/geojson@npm:*": + version: 7946.0.16 + resolution: "@types/geojson@npm:7946.0.16" + checksum: 10/34d07421bdd60e7b99fa265441d17ac6e9aef48e3ce22d04324127d0de1daf7fbaa0bd3be1cece2092eb6995f21da84afa5231e24621a2910ff7340bc98f496f + languageName: node + linkType: hard + "@types/glob@npm:^8.1.0": version: 8.1.0 resolution: "@types/glob@npm:8.1.0" @@ -11759,6 +11775,25 @@ __metadata: languageName: node linkType: hard +"@types/topojson-client@npm:^3.1.5": + version: 3.1.5 + resolution: "@types/topojson-client@npm:3.1.5" + dependencies: + "@types/geojson": "npm:*" + "@types/topojson-specification": "npm:*" + checksum: 10/f51702f789ef650958e381418349ec180c4cd1b575ea3962266b97f499baf14f8d6a7405e36e740776c51864b56f790212e9d88da547ec61e1d68d3191ab8364 + languageName: node + linkType: hard + +"@types/topojson-specification@npm:*, @types/topojson-specification@npm:^1.0.5": + version: 1.0.5 + resolution: "@types/topojson-specification@npm:1.0.5" + dependencies: + "@types/geojson": "npm:*" + checksum: 10/8c879e48317e805a0eeebfa54fcb5418e477c4e2e5712a23de1d0e038b0ab6afe806a91fc40452cd3bc931de6a141b53920c7f443d767aadf408dde757b1807c + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.2 resolution: "@types/unist@npm:3.0.2" @@ -15186,6 +15221,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:2, commander@npm:^2.20.0": + version: 2.20.3 + resolution: "commander@npm:2.20.3" + checksum: 10/90c5b6898610cd075984c58c4f88418a4fb44af08c1b1415e9854c03171bec31b336b7f3e4cefe33de994b3f12b03c5e2d638da4316df83593b9e82554e7e95b + languageName: node + linkType: hard + "commander@npm:^11.1.0": version: 11.1.0 resolution: "commander@npm:11.1.0" @@ -15200,13 +15242,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^2.20.0": - version: 2.20.3 - resolution: "commander@npm:2.20.3" - checksum: 10/90c5b6898610cd075984c58c4f88418a4fb44af08c1b1415e9854c03171bec31b336b7f3e4cefe33de994b3f12b03c5e2d638da4316df83593b9e82554e7e95b - languageName: node - linkType: hard - "commander@npm:^4.0.0": version: 4.1.1 resolution: "commander@npm:4.1.1" @@ -15572,6 +15607,24 @@ __metadata: languageName: node linkType: hard +"d3-array@npm:2.5.0 - 3": + version: 3.2.4 + resolution: "d3-array@npm:3.2.4" + dependencies: + internmap: "npm:1 - 2" + checksum: 10/5800c467f89634776a5977f6dae3f4e127d91be80f1d07e3e6e35303f9de93e6636d014b234838eea620f7469688d191b3f41207a30040aab750a63c97ec1d7c + languageName: node + linkType: hard + +"d3-geo@npm:^3.1.1": + version: 3.1.1 + resolution: "d3-geo@npm:3.1.1" + dependencies: + d3-array: "npm:2.5.0 - 3" + checksum: 10/dc5e980330d891dabf92869b98871b05ca2021c64d7ef253bcfd4f2348839ad33576fba474baecc2def86ebd3d943a11d93c0af26be0a2694f5bd59824838133 + languageName: node + linkType: hard + "data-urls@npm:^5.0.0": version: 5.0.0 resolution: "data-urls@npm:5.0.0" @@ -17667,14 +17720,18 @@ __metadata: "@tldraw/dotcom-shared": "workspace:*" "@tldraw/state": "workspace:*" "@tldraw/sync": "workspace:*" + "@types/d3-geo": "npm:^3.1.0" "@types/lodash": "npm:^4.17.14" "@types/react": "npm:^19.2.7" "@types/react-dom": "npm:^19.2.3" + "@types/topojson-client": "npm:^3.1.5" + "@types/topojson-specification": "npm:^1.0.5" "@vitejs/plugin-react-swc": "npm:^3.10.2" ag-grid-community: "npm:^32.3.3" ag-grid-react: "npm:^32.3.3" axe-core: "npm:^4.10.3" classnames: "npm:^2.5.1" + d3-geo: "npm:^3.1.1" dotenv: "npm:^16.4.7" lazyrepo: "npm:0.0.0-alpha.27" lodash: "npm:^4.17.21" @@ -17689,6 +17746,8 @@ __metadata: remark-frontmatter: "npm:^5.0.0" remark-html: "npm:^16.0.1" tldraw: "workspace:*" + topojson-client: "npm:^3.1.0" + us-atlas: "npm:^3.0.1" vfile-matter: "npm:^5.0.0" vite: "npm:^7.0.1" languageName: unknown @@ -20000,6 +20059,13 @@ __metadata: languageName: node linkType: hard +"internmap@npm:1 - 2": + version: 2.0.3 + resolution: "internmap@npm:2.0.3" + checksum: 10/873e0e7fcfe32f999aa0997a0b648b1244508e56e3ea6b8259b5245b50b5eeb3853fba221f96692bd6d1def501da76c32d64a5cb22a0b26cdd9b445664f805e0 + languageName: node + linkType: hard + "intl-messageformat@npm:11.1.2, intl-messageformat@npm:^11.1.2": version: 11.1.2 resolution: "intl-messageformat@npm:11.1.2" @@ -29178,6 +29244,19 @@ __metadata: languageName: node linkType: hard +"topojson-client@npm:^3.1.0": + version: 3.1.0 + resolution: "topojson-client@npm:3.1.0" + dependencies: + commander: "npm:2" + bin: + topo2geo: bin/topo2geo + topomerge: bin/topomerge + topoquantize: bin/topoquantize + checksum: 10/a0bd2f313dfeb2893ccbe00492f47518c62f0da41f6ee16a9d362dced57f79ba5bd51ae62038387b446463cd8e033d68a217c98c62d8b5fe2b807d9dd18406fc + languageName: node + linkType: hard + "totalist@npm:^3.0.0": version: 3.0.1 resolution: "totalist@npm:3.0.1" @@ -30111,6 +30190,13 @@ __metadata: languageName: node linkType: hard +"us-atlas@npm:^3.0.1": + version: 3.0.1 + resolution: "us-atlas@npm:3.0.1" + checksum: 10/dab562c6443be82f024b6de8a6bf175381a5a026aa70d058999f49c204e1795ecf93bf120e2ea73c9532ee8af6826cd7e8e837d7c4bdc6be12b997bfe0bbc9ca + languageName: node + linkType: hard + "use-callback-ref@npm:^1.3.3": version: 1.3.3 resolution: "use-callback-ref@npm:1.3.3"