React-powered SVG exports (#3117)

## Migration path
1. If any of your shapes implement `toSvg` for exports, you'll need to
replace your implementation with a new version that returns JSX (it's a
react component) instead of manually constructing SVG DOM nodes
2. `editor.getSvg` is deprecated. It still works, but will be going away
in a future release. If you still need SVGs as DOM elements rather than
strings, use `new DOMParser().parseFromString(svgString,
'image/svg+xml').firstElementChild`

## The change in detail
At the moment, our SVG exports very carefully try to recreate the
visuals of our shapes by manually constructing SVG DOM nodes. On its own
this is really painful, but it also results in a lot of duplicated logic
between the `component` and `getSvg` methods of shape utils.

In #3020, we looked at using string concatenation & DOMParser to make
this a bit less painful. This works, but requires specifying namespaces
everywhere, is still pretty painful (no syntax highlighting or
formatting), and still results in all that duplicated logic.

I briefly experimented with creating my own version of the javascript
language that let you embed XML like syntax directly. I was going to
call it EXTREME JAVASCRIPT or XJS for short, but then I noticed that we
already wrote the whole of tldraw in this thing called react and a (imo
much worse named) version of the javascript xml thing already existed.

Given the entire library already depends on react, what would it look
like if we just used react directly for these exports? Turns out things
get a lot simpler! Take a look at lmk what you think

This diff was intended as a proof of concept, but is actually pretty
close to being landable. The main thing is that here, I've deliberately
leant into this being a big breaking change to see just how much code we
could delete (turns out: lots). We could if we wanted to make this
without making it a breaking change at all, but it would add back a lot
of complexity on our side and run a fair bit slower

---------

Co-authored-by: huppy-bot[bot] <128400622+huppy-bot[bot]@users.noreply.github.com>
This commit is contained in:
alex 2024-03-25 14:16:55 +00:00 committed by GitHub
parent 016dcdc56a
commit 05f58f7c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 1804 additions and 3465 deletions

View file

@ -2,3 +2,6 @@ global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
process.env.MULTIPLAYER_SERVER = 'https://localhost:8787'
process.env.ASSET_UPLOAD = 'https://localhost:8788'
global.TextEncoder = require('util').TextEncoder
global.TextDecoder = require('util').TextDecoder

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1,5 +1,4 @@
import { equals, getObjectSubset, iterableEquality, subsetEquality } from '@jest/expect-utils'
import {
matcherHint,
printDiffOrStringify,
@ -7,6 +6,10 @@ import {
printReceived,
stringify,
} from 'jest-matcher-utils'
import { TextDecoder, TextEncoder } from 'util'
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
function convertNumbersInObject(obj: any, roundToNearest: number) {
if (!obj) return obj

View file

@ -26,6 +26,7 @@ import { PointerEventHandler } from 'react';
import { react } from '@tldraw/state';
import { default as React_2 } from 'react';
import * as React_3 from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { SerializedSchema } from '@tldraw/store';
import { SerializedStore } from '@tldraw/store';
@ -766,7 +767,24 @@ export class Editor extends EventEmitter<TLEventMap> {
getSortedChildIdsForParent(parent: TLPage | TLParentId | TLShape): TLShapeId[];
getStateDescendant<T extends StateNode>(path: string): T | undefined;
getStyleForNextShape<T>(style: StyleProp<T>): T;
// @deprecated (undocumented)
getSvg(shapes: TLShape[] | TLShapeId[], opts?: Partial<TLSvgOptions>): Promise<SVGSVGElement | undefined>;
getSvgString(shapes: TLShape[] | TLShapeId[], opts?: Partial<TLSvgOptions>): Promise<{
svg: string;
width: number;
height: number;
} | undefined>;
// @internal (undocumented)
getUnorderedRenderingShapes(useEditorState: boolean): {
id: TLShapeId;
shape: TLShape;
util: ShapeUtil;
index: number;
backgroundIndex: number;
opacity: number;
isCulled: boolean;
maskedPageBounds: Box | undefined;
}[];
getViewportPageBounds(): Box;
getViewportPageCenter(): Vec;
getViewportScreenBounds(): Box;
@ -1654,8 +1672,8 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
static props?: ShapeProps<TLUnknownShape>;
// @internal
providesBackgroundForChildren(shape: Shape): boolean;
toBackgroundSvg?(shape: Shape, ctx: SvgExportContext): null | Promise<SVGElement> | SVGElement;
toSvg?(shape: Shape, ctx: SvgExportContext): Promise<SVGElement> | SVGElement;
toBackgroundSvg?(shape: Shape, ctx: SvgExportContext): null | Promise<null | ReactElement> | ReactElement;
toSvg?(shape: Shape, ctx: SvgExportContext): null | Promise<null | ReactElement> | ReactElement;
static type: string;
}
@ -1826,7 +1844,7 @@ export interface SvgExportContext {
// @public (undocumented)
export interface SvgExportDef {
// (undocumented)
getElement: () => null | Promise<null | SVGElement | SVGElement[]> | SVGElement | SVGElement[];
getElement: () => null | Promise<null | ReactElement> | ReactElement;
// (undocumented)
key: string;
}
@ -2727,6 +2745,11 @@ export function useShallowArrayIdentity<T>(arr: readonly T[]): readonly T[];
// @internal (undocumented)
export function useShallowObjectIdentity<T extends Record<string, unknown>>(arr: T): T;
// @public
export function useSvgExportContext(): {
isDarkMode: boolean;
} | null;
// @public (undocumented)
export function useTLStore(opts: TLStoreOptions & {
snapshot?: StoreSnapshot<TLRecord>;

View file

@ -13884,7 +13884,7 @@
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getSvg:member(1)",
"docComment": "/**\n * Get an exported SVG of the given shapes.\n *\n * @param ids - The shapes (or shape ids) to export.\n *\n * @param opts - Options for the export.\n *\n * @returns The SVG element.\n *\n * @public\n */\n",
"docComment": "/**\n * @deprecated\n *\n * Use {@link Editor.getSvgString} instead\n */\n",
"excerptTokens": [
{
"kind": "Content",
@ -13987,6 +13987,103 @@
"isAbstract": false,
"name": "getSvg"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getSvgString:member(1)",
"docComment": "/**\n * Get an exported SVG string of the given shapes.\n *\n * @param ids - The shapes (or shape ids) to export.\n *\n * @param opts - Options for the export.\n *\n * @returns The SVG element.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getSvgString(shapes: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "[] | "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ", opts?: "
},
{
"kind": "Reference",
"text": "Partial",
"canonicalReference": "!Partial:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLSvgOptions",
"canonicalReference": "@tldraw/editor!TLSvgOptions:type"
},
{
"kind": "Content",
"text": ">"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "Promise",
"canonicalReference": "!Promise:interface"
},
{
"kind": "Content",
"text": "<{\n svg: string;\n width: number;\n height: number;\n } | undefined>"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 13
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shapes",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
},
"isOptional": false
},
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 10
},
"isOptional": true
}
],
"isOptional": false,
"isAbstract": false,
"name": "getSvgString"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getViewportPageBounds:member(1)",
@ -24629,7 +24726,7 @@
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!isSafeFloat:function(1)",
"docComment": "/**\n * Check if a float is safe to use. ie: Not too big or small.\n *\n * @public\n */\n",
"docComment": "/**\n * ] Check if a float is safe to use. ie: Not too big or small.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
@ -32096,12 +32193,12 @@
},
{
"kind": "Content",
"text": "<"
"text": "<null | "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"text": "ReactElement",
"canonicalReference": "@types/react!React.ReactElement:interface"
},
{
"kind": "Content",
@ -32109,8 +32206,8 @@
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"text": "ReactElement",
"canonicalReference": "@types/react!React.ReactElement:interface"
},
{
"kind": "Content",
@ -32173,6 +32270,10 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "null | "
},
{
"kind": "Reference",
"text": "Promise",
@ -32180,12 +32281,12 @@
},
{
"kind": "Content",
"text": "<"
"text": "<null | "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"text": "ReactElement",
"canonicalReference": "@types/react!React.ReactElement:interface"
},
{
"kind": "Content",
@ -32193,8 +32294,8 @@
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"text": "ReactElement",
"canonicalReference": "@types/react!React.ReactElement:interface"
},
{
"kind": "Content",
@ -32204,7 +32305,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 10
"endIndex": 11
},
"releaseTag": "Public",
"isProtected": false,
@ -34737,39 +34838,17 @@
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"text": "ReactElement",
"canonicalReference": "@types/react!React.ReactElement:interface"
},
{
"kind": "Content",
"text": " | "
"text": "> | "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
},
{
"kind": "Content",
"text": "[]> | "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
},
{
"kind": "Content",
"text": " | "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
},
{
"kind": "Content",
"text": "[]"
"text": "ReactElement",
"canonicalReference": "@types/react!React.ReactElement:interface"
},
{
"kind": "Content",
@ -34782,7 +34861,7 @@
"name": "getElement",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 12
"endIndex": 7
}
},
{
@ -43050,6 +43129,34 @@
],
"name": "useSelectionEvents"
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!useSvgExportContext:function(1)",
"docComment": "/**\n * Returns the read-only parts of {@link SvgExportContext}.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function useSvgExportContext(): "
},
{
"kind": "Content",
"text": "{\n isDarkMode: boolean;\n} | null"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/editor/types/SvgExportContext.tsx",
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [],
"name": "useSvgExportContext"
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!useTLStore:function(1)",

View file

@ -13,3 +13,6 @@ document.fonts = {
forEach: () => {},
[Symbol.iterator]: () => [][Symbol.iterator](),
}
global.TextEncoder = require('util').TextEncoder
global.TextDecoder = require('util').TextDecoder

View file

@ -192,7 +192,11 @@ export { getArrowTerminalsInArrowSpace } from './lib/editor/shapes/shared/arrow/
export { resizeBox, type ResizeBoxOptions } from './lib/editor/shapes/shared/resizeBox'
export { BaseBoxShapeTool } from './lib/editor/tools/BaseBoxShapeTool/BaseBoxShapeTool'
export { StateNode, type TLStateNodeConstructor } from './lib/editor/tools/StateNode'
export { type SvgExportContext, type SvgExportDef } from './lib/editor/types/SvgExportContext'
export {
useSvgExportContext,
type SvgExportContext,
type SvgExportDef,
} from './lib/editor/types/SvgExportContext'
export { type TLContent } from './lib/editor/types/clipboard-types'
export { type TLEventMap, type TLEventMapHandler } from './lib/editor/types/emit-types'
export {

View file

@ -481,7 +481,7 @@ function CollaboratorHintDef() {
function DebugSvgCopy({ id }: { id: TLShapeId }) {
const editor = useEditor()
const [html, setHtml] = useState('')
const [src, setSrc] = useState<string | null>(null)
const isInRoot = useValue(
'is in root',
@ -499,18 +499,15 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) {
const unsubscribe = react('shape to svg', async () => {
const renderId = Math.random()
latest = renderId
const bb = editor.getShapePageBounds(id)
const el = await editor.getSvg([id], {
const result = await editor.getSvgString([id], {
padding: 0,
background: editor.getInstanceState().exportBackground,
})
if (el && bb && latest === renderId) {
el.style.setProperty('overflow', 'visible')
el.setAttribute('preserveAspectRatio', 'xMidYMin slice')
el.style.setProperty('transform', `translate(${bb.x}px, ${bb.y + bb.h + 12}px)`)
el.style.setProperty('border', '1px solid black')
setHtml(el?.outerHTML)
}
if (latest !== renderId || !result) return
const svgDataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(result.svg)}`
setSrc(svgDataUrl)
})
return () => {
@ -518,13 +515,24 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) {
unsubscribe()
}
}, [editor, id, isInRoot])
const bb = editor.getShapePageBounds(id)
if (!isInRoot) return null
if (!isInRoot || !src || !bb) return null
return (
<div style={{ paddingTop: 12, position: 'absolute' }}>
<div style={{ display: 'flex' }} dangerouslySetInnerHTML={{ __html: html }} />
</div>
<img
src={src}
width={bb.width}
height={bb.height}
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translate(${bb.x}px, ${bb.y + bb.h + 12}px)`,
border: '1px solid black',
maxWidth: 'none',
}}
/>
)
}

View file

@ -34,7 +34,6 @@ import {
TLUnknownShape,
TLVideoAsset,
createShapeId,
getDefaultColorTheme,
getShapePropKeysByStyle,
isPageId,
isShape,
@ -60,6 +59,9 @@ import {
structuredClone,
} from '@tldraw/utils'
import { EventEmitter } from 'eventemitter3'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { TLUser, createTLUser } from '../config/createTLUser'
import { checkShapesAndAddCore } from '../config/defaultShapes'
import {
@ -82,7 +84,6 @@ import {
MAX_SHAPES_PER_PAGE,
MAX_ZOOM,
MIN_ZOOM,
SVG_PADDING,
ZOOMS,
} from '../constants'
import { Box } from '../primitives/Box'
@ -103,6 +104,7 @@ import { uniqueId } from '../utils/uniqueId'
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
import { parentsToChildren } from './derivations/parentsToChildren'
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
import { getSvgJsx } from './getSvgJsx'
import { ClickManager } from './managers/ClickManager'
import { EnvironmentManager } from './managers/EnvironmentManager'
import { HistoryManager } from './managers/HistoryManager'
@ -119,7 +121,6 @@ import { getArrowTerminalsInArrowSpace, getIsArrowStraight } from './shapes/shar
import { getStraightArrowInfo } from './shapes/shared/arrow/straight-arrow'
import { RootState } from './tools/RootState'
import { StateNode, TLStateNodeConstructor } from './tools/StateNode'
import { SvgExportContext, SvgExportDef } from './types/SvgExportContext'
import { TLContent } from './types/clipboard-types'
import { TLEventMap } from './types/emit-types'
import {
@ -3091,7 +3092,8 @@ export class Editor extends EventEmitter<TLEventMap> {
}
}
private getUnorderedRenderingShapes(
/** @internal */
getUnorderedRenderingShapes(
// The rendering state. We use this method both for rendering, which
// is based on other state, and for computing order for SVG export,
// which should work even when things are for example off-screen.
@ -8080,7 +8082,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
/**
* Get an exported SVG of the given shapes.
* Get an exported SVG string of the given shapes.
*
* @param ids - The shapes (or shape ids) to export.
* @param opts - Options for the export.
@ -8089,220 +8091,22 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
async getSvgString(shapes: TLShapeId[] | TLShape[], opts = {} as Partial<TLSvgOptions>) {
const svg = await getSvgJsx(this, shapes, opts)
if (!svg) return undefined
return { svg: renderToStaticMarkup(svg.jsx), width: svg.width, height: svg.height }
}
/** @deprecated Use {@link Editor.getSvgString} instead */
async getSvg(shapes: TLShapeId[] | TLShape[], opts = {} as Partial<TLSvgOptions>) {
const ids =
typeof shapes[0] === 'string'
? (shapes as TLShapeId[])
: (shapes as TLShape[]).map((s) => s.id)
if (ids.length === 0) return
if (!window.document) throw Error('No document')
const {
scale = 1,
background = false,
padding = SVG_PADDING,
preserveAspectRatio = false,
} = opts
const isDarkMode = opts.darkMode ?? this.user.getIsDarkMode()
const theme = getDefaultColorTheme({ isDarkMode })
// ---Figure out which shapes we need to include
const shapeIdsToInclude = this.getShapeAndDescendantIds(ids)
const renderingShapes = this.getUnorderedRenderingShapes(false).filter(({ id }) =>
shapeIdsToInclude.has(id)
)
// --- Common bounding box of all shapes
let bbox = null
if (opts.bounds) {
bbox = opts.bounds
} else {
for (const { maskedPageBounds } of renderingShapes) {
if (!maskedPageBounds) continue
if (bbox) {
bbox.union(maskedPageBounds)
} else {
bbox = maskedPageBounds.clone()
}
}
}
// no unmasked shapes to export
if (!bbox) return
const singleFrameShapeId =
ids.length === 1 && this.isShapeOfType<TLFrameShape>(this.getShape(ids[0])!, 'frame')
? ids[0]
: null
if (!singleFrameShapeId) {
// Expand by an extra 32 pixels
bbox.expandBy(padding)
}
// We want the svg image to be BIGGER THAN USUAL to account for image quality
const w = bbox.width * scale
const h = bbox.height * scale
// --- Create the SVG
// Embed our custom fonts
const svg = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg')
if (preserveAspectRatio) {
svg.setAttribute('preserveAspectRatio', preserveAspectRatio)
}
svg.setAttribute('direction', 'ltr')
svg.setAttribute('width', w + '')
svg.setAttribute('height', h + '')
svg.setAttribute('viewBox', `${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}`)
svg.setAttribute('stroke-linecap', 'round')
svg.setAttribute('stroke-linejoin', 'round')
// Add current background color, or else background will be transparent
if (background) {
if (singleFrameShapeId) {
svg.style.setProperty('background', theme.solid)
} else {
svg.style.setProperty('background-color', theme.background)
}
} else {
svg.style.setProperty('background-color', 'transparent')
}
try {
document.body.focus?.() // weird but necessary
} catch (e) {
// not implemented
}
// Add the defs to the svg
const defs = window.document.createElementNS('http://www.w3.org/2000/svg', 'defs')
svg.append(defs)
const exportDefPromisesById = new Map<string, Promise<void>>()
const exportContext: SvgExportContext = {
isDarkMode,
addExportDef: (def: SvgExportDef) => {
if (exportDefPromisesById.has(def.key)) return
const promise = (async () => {
const elements = await def.getElement()
if (!elements) return
const comment = document.createComment(`def: ${def.key}`)
defs.appendChild(comment)
for (const element of Array.isArray(elements) ? elements : [elements]) {
defs.appendChild(element)
}
})()
exportDefPromisesById.set(def.key, promise)
},
}
const unorderedShapeElements = (
await Promise.all(
renderingShapes.map(async ({ id, opacity, index, backgroundIndex }) => {
// Don't render the frame if we're only exporting a single frame
if (id === singleFrameShapeId) return []
const shape = this.getShape(id)!
if (this.isShapeOfType<TLGroupShape>(shape, 'group')) return []
const util = this.getShapeUtil(shape)
let shapeSvgElement = await util.toSvg?.(shape, exportContext)
let backgroundSvgElement = await util.toBackgroundSvg?.(shape, exportContext)
// wrap the shapes in groups so we can apply properties without overwriting ones from the shape util
if (shapeSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.appendChild(shapeSvgElement)
shapeSvgElement = outerElement
}
if (backgroundSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.appendChild(backgroundSvgElement)
backgroundSvgElement = outerElement
}
if (!shapeSvgElement && !backgroundSvgElement) {
const bounds = this.getShapePageBounds(shape)!
const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect')
elm.setAttribute('width', bounds.width + '')
elm.setAttribute('height', bounds.height + '')
elm.setAttribute('fill', theme.solid)
elm.setAttribute('stroke', theme.grey.pattern)
elm.setAttribute('stroke-width', '1')
shapeSvgElement = elm
}
let pageTransform = this.getShapePageTransform(shape)!.toCssString()
if ('scale' in shape.props) {
if (shape.props.scale !== 1) {
pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})`
}
}
shapeSvgElement?.setAttribute('transform', pageTransform)
backgroundSvgElement?.setAttribute('transform', pageTransform)
shapeSvgElement?.setAttribute('opacity', opacity + '')
backgroundSvgElement?.setAttribute('opacity', opacity + '')
// Create svg mask if shape has a frame as parent
const pageMask = this.getShapeMask(shape.id)
if (pageMask) {
// Create a clip path and add it to defs
const clipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath')
defs.appendChild(clipPathEl)
const id = uniqueId()
clipPathEl.id = id
// Create a polyline mask that does the clipping
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mask.setAttribute('d', `M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`)
clipPathEl.appendChild(mask)
// Create group that uses the clip path and wraps the shape elements
if (shapeSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.setAttribute('clip-path', `url(#${id})`)
outerElement.appendChild(shapeSvgElement)
shapeSvgElement = outerElement
}
if (backgroundSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.setAttribute('clip-path', `url(#${id})`)
outerElement.appendChild(backgroundSvgElement)
backgroundSvgElement = outerElement
}
}
const elements = []
if (shapeSvgElement) {
elements.push({ zIndex: index, element: shapeSvgElement })
}
if (backgroundSvgElement) {
elements.push({ zIndex: backgroundIndex, element: backgroundSvgElement })
}
return elements
})
)
).flat()
await Promise.all(exportDefPromisesById.values())
for (const { element } of unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex)) {
svg.appendChild(element)
}
return svg
const svg = await getSvgJsx(this, shapes, opts)
if (!svg) return undefined
const fragment = new DocumentFragment()
const root = createRoot(fragment)
flushSync(() => root.render(svg.jsx))
const rendered = fragment.firstElementChild
root.unmount()
return rendered as SVGSVGElement
}
/* --------------------- Events --------------------- */

View file

@ -0,0 +1,209 @@
import {
TLFrameShape,
TLGroupShape,
TLShape,
TLShapeId,
getDefaultColorTheme,
} from '@tldraw/tlschema'
import { Fragment, ReactElement } from 'react'
import { SVG_PADDING } from '../constants'
import { Editor } from './Editor'
import { SvgExportContext, SvgExportContextProvider, SvgExportDef } from './types/SvgExportContext'
import { TLSvgOptions } from './types/misc-types'
export async function getSvgJsx(
editor: Editor,
shapes: TLShapeId[] | TLShape[],
opts = {} as Partial<TLSvgOptions>
) {
const ids =
typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id)
if (ids.length === 0) return
if (!window.document) throw Error('No document')
const { scale = 1, background = false, padding = SVG_PADDING, preserveAspectRatio = false } = opts
const isDarkMode = opts.darkMode ?? editor.user.getIsDarkMode()
const theme = getDefaultColorTheme({ isDarkMode })
// ---Figure out which shapes we need to include
const shapeIdsToInclude = editor.getShapeAndDescendantIds(ids)
const renderingShapes = editor
.getUnorderedRenderingShapes(false)
.filter(({ id }) => shapeIdsToInclude.has(id))
// --- Common bounding box of all shapes
let bbox = null
if (opts.bounds) {
bbox = opts.bounds
} else {
for (const { maskedPageBounds } of renderingShapes) {
if (!maskedPageBounds) continue
if (bbox) {
bbox.union(maskedPageBounds)
} else {
bbox = maskedPageBounds.clone()
}
}
}
// no unmasked shapes to export
if (!bbox) return
const singleFrameShapeId =
ids.length === 1 && editor.isShapeOfType<TLFrameShape>(editor.getShape(ids[0])!, 'frame')
? ids[0]
: null
if (!singleFrameShapeId) {
// Expand by an extra 32 pixels
bbox.expandBy(padding)
}
// We want the svg image to be BIGGER THAN USUAL to account for image quality
const w = bbox.width * scale
const h = bbox.height * scale
try {
document.body.focus?.() // weird but necessary
} catch (e) {
// not implemented
}
const defChildren: ReactElement[] = []
const exportDefPromisesById = new Map<string, Promise<void>>()
const exportContext: SvgExportContext = {
isDarkMode,
addExportDef: (def: SvgExportDef) => {
if (exportDefPromisesById.has(def.key)) return
const promise = (async () => {
const element = await def.getElement()
if (!element) return
defChildren.push(<Fragment key={defChildren.length}>{element}</Fragment>)
})()
exportDefPromisesById.set(def.key, promise)
},
}
const unorderedShapeElements = (
await Promise.all(
renderingShapes.map(async ({ id, opacity, index, backgroundIndex }) => {
// Don't render the frame if we're only exporting a single frame
if (id === singleFrameShapeId) return []
const shape = editor.getShape(id)!
if (editor.isShapeOfType<TLGroupShape>(shape, 'group')) return []
const util = editor.getShapeUtil(shape)
let toSvgResult = await util.toSvg?.(shape, exportContext)
let toBackgroundSvgResult = await util.toBackgroundSvg?.(shape, exportContext)
if (!toSvgResult && !toBackgroundSvgResult) {
const bounds = editor.getShapePageBounds(shape)!
toSvgResult = (
<rect
width={bounds.w}
height={bounds.h}
fill={theme.solid}
stroke={theme.grey.pattern}
strokeWidth={1}
/>
)
}
let pageTransform = editor.getShapePageTransform(shape)!.toCssString()
if ('scale' in shape.props) {
if (shape.props.scale !== 1) {
pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})`
}
}
if (toSvgResult) {
toSvgResult = (
<g key={shape.id} transform={pageTransform} opacity={opacity}>
{toSvgResult}
</g>
)
}
if (toBackgroundSvgResult) {
toBackgroundSvgResult = (
<g key={`bg_${shape.id}`} transform={pageTransform} opacity={opacity}>
{toBackgroundSvgResult}
</g>
)
}
// Create svg mask if shape has a frame as parent
const pageMask = editor.getShapeMask(shape.id)
if (pageMask) {
// Create a clip path and add it to defs
const pageMaskId = `mask_${shape.id.replace(':', '_')}`
defChildren.push(
<clipPath key={defChildren.length} id={pageMaskId}>
{/* Create a polyline mask that does the clipping */}
<path d={`M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`} />
</clipPath>
)
if (toSvgResult) {
toSvgResult = (
<g key={shape.id} clipPath={`url(#${pageMaskId})`}>
{toSvgResult}
</g>
)
}
if (toBackgroundSvgResult) {
toBackgroundSvgResult = (
<g key={`bg_${shape.id}`} clipPath={`url(#${pageMaskId})`}>
{toBackgroundSvgResult}
</g>
)
}
}
const elements = []
if (toSvgResult) {
elements.push({ zIndex: index, element: toSvgResult })
}
if (toBackgroundSvgResult) {
elements.push({ zIndex: backgroundIndex, element: toBackgroundSvgResult })
}
return elements
})
)
).flat()
await Promise.all(exportDefPromisesById.values())
const svg = (
<SvgExportContextProvider editor={editor} context={exportContext}>
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio={preserveAspectRatio ? preserveAspectRatio : undefined}
direction="ltr"
width={w}
height={h}
viewBox={`${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}`}
strokeLinecap="round"
strokeLinejoin="round"
style={{
backgroundColor: background
? singleFrameShapeId
? theme.solid
: theme.background
: 'transparent',
}}
>
<defs>{defChildren}</defs>
{unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex).map(({ element }) => element)}
</svg>
</SvgExportContextProvider>
)
return { jsx: svg, width: w, height: h }
}

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Migrations } from '@tldraw/store'
import { ShapeProps, TLHandle, TLShape, TLShapePartial, TLUnknownShape } from '@tldraw/tlschema'
import { ReactElement } from 'react'
import { Box } from '../../primitives/Box'
import { Vec } from '../../primitives/Vec'
import { Geometry2d } from '../../primitives/geometry/Geometry2d'
@ -230,7 +231,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @returns An SVG element.
* @public
*/
toSvg?(shape: Shape, ctx: SvgExportContext): SVGElement | Promise<SVGElement>
toSvg?(shape: Shape, ctx: SvgExportContext): ReactElement | null | Promise<ReactElement | null>
/**
* Get the shape's background layer as an SVG object.
@ -240,7 +241,10 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @returns An SVG element.
* @public
*/
toBackgroundSvg?(shape: Shape, ctx: SvgExportContext): SVGElement | Promise<SVGElement> | null
toBackgroundSvg?(
shape: Shape,
ctx: SvgExportContext
): ReactElement | null | Promise<ReactElement | null>
/** @internal */
expandSelectionOutlinePx(shape: Shape): number {

View file

@ -119,8 +119,6 @@ export const MIN_ARROW_LENGTH = 10
/** @internal */
export const BOUND_ARROW_OFFSET = 10
/** @internal */
export const LABEL_TO_ARROW_PADDING = 20
/** @internal */
export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10
/** @public */

View file

@ -1,7 +1,11 @@
import { ReactElement, ReactNode, createContext, useContext } from 'react'
import { EditorContext } from '../../hooks/useEditor'
import { Editor } from '../Editor'
/** @public */
export interface SvgExportDef {
key: string
getElement: () => Promise<SVGElement | SVGElement[] | null> | SVGElement | SVGElement[] | null
getElement: () => Promise<ReactElement | null> | ReactElement | null
}
/** @public */
@ -17,3 +21,30 @@ export interface SvgExportContext {
*/
readonly isDarkMode: boolean
}
const Context = createContext<SvgExportContext | null>(null)
export function SvgExportContextProvider({
context,
editor,
children,
}: {
context: SvgExportContext
editor: Editor
children: ReactNode
}) {
return (
<EditorContext.Provider value={editor}>
<Context.Provider value={context}>{children}</Context.Provider>
</EditorContext.Provider>
)
}
/**
* Returns the read-only parts of {@link SvgExportContext}.
* @public
*/
export function useSvgExportContext() {
const ctx = useContext(Context)
if (!ctx) return null
return { isDarkMode: ctx.isDarkMode }
}

View file

@ -1,8 +1,13 @@
import { useValue } from '@tldraw/state'
import { useSvgExportContext } from '../editor/types/SvgExportContext'
import { useEditor } from './useEditor'
/** @public */
export function useIsDarkMode() {
const editor = useEditor()
return useValue('isDarkMode', () => editor.user.getIsDarkMode(), [editor])
const exportContext = useSvgExportContext()
return useValue('isDarkMode', () => exportContext?.isDarkMode ?? editor.user.getIsDarkMode(), [
exportContext,
editor,
])
}

View file

@ -339,17 +339,17 @@ export function pointInPolygon(A: VecLike, points: VecLike[]): boolean {
* @public
*/
export function toDomPrecision(v: number) {
return +v.toFixed(4)
return Math.round(v * 1e4) / 1e4
}
/**
* @public
*/
export function toFixed(v: number) {
return +v.toFixed(2)
return Math.round(v * 1e2) / 1e2
}
/**
/**]
* Check if a float is safe to use. ie: Not too big or small.
* @public
*/

View file

@ -240,7 +240,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
labelPosition: Validator<number>;
};
// (undocumented)
toSvg(shape: TLArrowShape, ctx: SvgExportContext): SVGGElement;
toSvg(shape: TLArrowShape, ctx: SvgExportContext): JSX_2.Element;
// (undocumented)
static type: "arrow";
}
@ -499,7 +499,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
isPen: Validator<boolean>;
};
// (undocumented)
toSvg(shape: TLDrawShape, ctx: SvgExportContext): SVGGElement;
toSvg(shape: TLDrawShape, ctx: SvgExportContext): JSX_2.Element;
// (undocumented)
static type: "draw";
}
@ -664,7 +664,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// (undocumented)
providesBackgroundForChildren(): boolean;
// (undocumented)
toSvg(shape: TLFrameShape, ctx: SvgExportContext): Promise<SVGElement> | SVGElement;
toSvg(shape: TLFrameShape, ctx: SvgExportContext): JSX_2.Element;
// (undocumented)
static type: "frame";
}
@ -816,7 +816,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
text: Validator<string>;
};
// (undocumented)
toSvg(shape: TLGeoShape, ctx: SvgExportContext): SVGElement;
toSvg(shape: TLGeoShape, ctx: SvgExportContext): JSX_2.Element;
// (undocumented)
static type: "geo";
}
@ -830,15 +830,14 @@ export function GeoStylePickerSet({ styles }: {
export function getEmbedInfo(inputUrl: string): TLEmbedResult;
// @public (undocumented)
export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: {
export function getSvgAsImage(svgString: string, isSafari: boolean, options: {
type: 'jpeg' | 'png' | 'webp';
quality: number;
scale: number;
width: number;
height: number;
}): Promise<Blob | null>;
// @public (undocumented)
export function getSvgAsString(svg: SVGElement): Promise<string>;
// @public (undocumented)
export function GroupMenuItem(): JSX_2.Element | null;
@ -915,9 +914,9 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
isPen: Validator<boolean>;
};
// (undocumented)
toBackgroundSvg(shape: TLHighlightShape): SVGPathElement;
toBackgroundSvg(shape: TLHighlightShape): JSX_2.Element;
// (undocumented)
toSvg(shape: TLHighlightShape, ctx: SvgExportContext): SVGPathElement;
toSvg(shape: TLHighlightShape): JSX_2.Element;
// (undocumented)
static type: "highlight";
}
@ -956,7 +955,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
} | null>;
};
// (undocumented)
toSvg(shape: TLImageShape): Promise<SVGGElement>;
toSvg(shape: TLImageShape): Promise<JSX_2.Element | null>;
// (undocumented)
static type: "image";
}
@ -1016,7 +1015,7 @@ export class LineShapeTool extends StateNode {
// @public (undocumented)
export class LineShapeUtil extends ShapeUtil<TLLineShape> {
// (undocumented)
component(shape: TLLineShape): JSX_2.Element | undefined;
component(shape: TLLineShape): JSX_2.Element;
// (undocumented)
getDefaultProps(): TLLineShape['props'];
// (undocumented)
@ -1055,7 +1054,7 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
}>;
};
// (undocumented)
toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement;
toSvg(shape: TLLineShape): JSX_2.Element;
// (undocumented)
static type: "line";
}
@ -1163,7 +1162,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
text: Validator<string>;
};
// (undocumented)
toSvg(shape: TLNoteShape, ctx: SvgExportContext): SVGGElement;
toSvg(shape: TLNoteShape, ctx: SvgExportContext): JSX_2.Element;
// (undocumented)
static type: "note";
}
@ -1393,7 +1392,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
autoSize: Validator<boolean>;
};
// (undocumented)
toSvg(shape: TLTextShape, ctx: SvgExportContext): SVGGElement;
toSvg(shape: TLTextShape, ctx: SvgExportContext): JSX_2.Element;
// (undocumented)
static type: "text";
}
@ -2563,7 +2562,7 @@ export class VideoShapeUtil extends BaseBoxShapeUtil<TLVideoShape> {
assetId: Validator<TLAssetId | null>;
};
// (undocumented)
toSvg(shape: TLVideoShape): SVGGElement;
toSvg(shape: TLVideoShape): JSX_2.Element;
// (undocumented)
static type: "video";
}

View file

@ -1737,10 +1737,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGGElement",
"canonicalReference": "!SVGGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -1750,7 +1754,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
"endIndex": 7
},
"releaseTag": "Public",
"isProtected": false,
@ -5270,10 +5274,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGGElement",
"canonicalReference": "!SVGGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -5283,7 +5291,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
"endIndex": 7
},
"releaseTag": "Public",
"isProtected": false,
@ -7753,28 +7761,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "Promise",
"canonicalReference": "!Promise:interface"
},
{
"kind": "Content",
"text": "<"
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
},
{
"kind": "Content",
"text": "> | "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -7784,7 +7778,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 10
"endIndex": 7
},
"releaseTag": "Public",
"isProtected": false,
@ -8975,10 +8969,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -8988,7 +8986,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
"endIndex": 7
},
"releaseTag": "Public",
"isProtected": false,
@ -9168,12 +9166,11 @@
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function getSvgAsImage(svg: "
"text": "export declare function getSvgAsImage(svgString: "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
@ -9189,7 +9186,7 @@
},
{
"kind": "Content",
"text": "{\n type: 'jpeg' | 'png' | 'webp';\n quality: number;\n scale: number;\n}"
"text": "{\n type: 'jpeg' | 'png' | 'webp';\n quality: number;\n scale: number;\n width: number;\n height: number;\n}"
},
{
"kind": "Content",
@ -9227,7 +9224,7 @@
"overloadIndex": 1,
"parameters": [
{
"parameterName": "svg",
"parameterName": "svgString",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
@ -9253,57 +9250,6 @@
],
"name": "getSvgAsImage"
},
{
"kind": "Function",
"canonicalReference": "tldraw!getSvgAsString:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function getSvgAsString(svg: "
},
{
"kind": "Reference",
"text": "SVGElement",
"canonicalReference": "!SVGElement:interface"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "Promise",
"canonicalReference": "!Promise:interface"
},
{
"kind": "Content",
"text": "<string>"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/utils/export/export.ts",
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 5
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "svg",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"name": "getSvgAsString"
},
{
"kind": "Function",
"canonicalReference": "tldraw!GroupMenuItem:function(1)",
@ -10475,10 +10421,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGPathElement",
"canonicalReference": "!SVGPathElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -10488,7 +10438,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
@ -10521,23 +10471,18 @@
"text": "TLHighlightShape",
"canonicalReference": "@tldraw/tlschema!TLHighlightShape:type"
},
{
"kind": "Content",
"text": ", ctx: "
},
{
"kind": "Reference",
"text": "SvgExportContext",
"canonicalReference": "@tldraw/editor!SvgExportContext:interface"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGPathElement",
"canonicalReference": "!SVGPathElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -10546,8 +10491,8 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
"startIndex": 3,
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
@ -10560,14 +10505,6 @@
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "ctx",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
@ -11144,16 +11081,16 @@
},
{
"kind": "Content",
"text": "<"
"text": "<import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGGElement",
"canonicalReference": "!SVGGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
"text": ">"
"text": " | null>"
},
{
"kind": "Content",
@ -11778,10 +11715,6 @@
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
"text": " | undefined"
},
{
"kind": "Content",
"text": ";"
@ -11790,7 +11723,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 6
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
@ -12403,23 +12336,18 @@
"text": "TLLineShape",
"canonicalReference": "@tldraw/tlschema!TLLineShape:type"
},
{
"kind": "Content",
"text": ", ctx: "
},
{
"kind": "Reference",
"text": "SvgExportContext",
"canonicalReference": "@tldraw/editor!SvgExportContext:interface"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGGElement",
"canonicalReference": "!SVGGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -12428,8 +12356,8 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
"startIndex": 3,
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
@ -12442,14 +12370,6 @@
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "ctx",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
@ -13504,10 +13424,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGGElement",
"canonicalReference": "!SVGGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -13517,7 +13441,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
"endIndex": 7
},
"releaseTag": "Public",
"isProtected": false,
@ -16070,10 +15994,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGGElement",
"canonicalReference": "!SVGGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -16083,7 +16011,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
"endIndex": 7
},
"releaseTag": "Public",
"isProtected": false,
@ -28609,10 +28537,14 @@
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "SVGGElement",
"canonicalReference": "!SVGGElement:interface"
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
@ -28622,7 +28554,7 @@
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,

View file

@ -67,3 +67,6 @@ window.DOMRect = class DOMRect {
this.height = height
}
}
global.TextEncoder = require('util').TextEncoder
global.TextDecoder = require('util').TextDecoder

View file

@ -109,7 +109,7 @@ export {
} from './lib/utils/assets/assets'
export { getEmbedInfo } from './lib/utils/embeds/embeds'
export { copyAs } from './lib/utils/export/copyAs'
export { exportToBlob, getSvgAsImage, getSvgAsString } from './lib/utils/export/export'
export { exportToBlob, getSvgAsImage } from './lib/utils/export/export'
export { exportAs } from './lib/utils/export/exportAs'
export { fitFrameToContent, removeFrame } from './lib/utils/frames/frames'
export { setDefaultEditorAssetUrls } from './lib/utils/static-assets/assetUrls'

View file

@ -14,7 +14,7 @@ import {
import { memo, useLayoutEffect, useMemo, useState } from 'react'
import { defaultShapeUtils } from './defaultShapeUtils'
import { usePreloadAssets } from './ui/hooks/usePreloadAssets'
import { getSvgAsImage, getSvgAsString } from './utils/export/export'
import { getSvgAsImage } from './utils/export/export'
import { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls'
/**
@ -108,7 +108,7 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) {
const shapeIds = editor.getCurrentPageShapeIds()
async function setSvg() {
const svg = await editor.getSvg([...shapeIds], {
const svgResult = await editor.getSvgString([...shapeIds], {
bounds,
scale,
background,
@ -117,19 +117,20 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) {
preserveAspectRatio,
})
if (svg && !isCancelled) {
if (svgResult && !isCancelled) {
if (format === 'svg') {
const string = await getSvgAsString(svg)
if (!isCancelled) {
const blob = new Blob([string], { type: 'image/svg+xml' })
const blob = new Blob([svgResult.svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
setUrl(url)
}
} else if (format === 'png') {
const blob = await getSvgAsImage(svg, editor.environment.isSafari, {
const blob = await getSvgAsImage(svgResult.svg, editor.environment.isSafari, {
type: format,
quality: 1,
scale: 2,
width: svgResult.width,
height: svgResult.height,
})
if (blob && !isCancelled) {
const url = URL.createObjectURL(blob)

View file

@ -1,8 +1,8 @@
import {
Arc2d,
Box,
DefaultFontFamilies,
Edge2d,
Editor,
Geometry2d,
Group2d,
Rectangle2d,
@ -10,11 +10,7 @@ import {
ShapeUtil,
SvgExportContext,
TLArrowShape,
TLArrowShapeArrowheadStyle,
TLArrowShapeProps,
TLDefaultColorStyle,
TLDefaultColorTheme,
TLDefaultFillStyle,
TLHandle,
TLOnEditEndHandler,
TLOnHandleDragHandler,
@ -28,17 +24,18 @@ import {
arrowShapeMigrations,
arrowShapeProps,
getArrowTerminalsInArrowSpace,
getDefaultColorTheme,
mapObjectMapValues,
objectMapEntries,
structuredClone,
toDomPrecision,
track,
useEditor,
useIsEditing,
} from '@tldraw/editor'
import React from 'react'
import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill'
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { ARROW_LABEL_FONT_SIZES, STROKE_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { ShapeFill, useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { ARROW_LABEL_FONT_SIZES, STROKE_SIZES } from '../shared/default-shape-constants'
import {
getFillDefForCanvas,
getFillDefForExport,
@ -133,14 +130,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
})
}
private getLength(shape: TLArrowShape): number {
const info = this.editor.getArrowInfo(shape)!
return info.isStraight
? Vec.Dist(info.start.handle, info.end.handle)
: Math.abs(info.handleArc.length)
}
override getHandles(shape: TLArrowShape): TLHandle[] {
const info = this.editor.getArrowInfo(shape)!
@ -531,7 +520,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
const onlySelectedShape = this.editor.getOnlySelectedShape()
const shouldDisplayHandles =
this.editor.isInAny(
'select.idle',
@ -542,156 +530,17 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
) && !this.editor.getInstanceState().isReadonly
const info = this.editor.getArrowInfo(shape)
const bounds = Box.ZeroFix(this.editor.getShapeGeometry(shape).bounds)
// eslint-disable-next-line react-hooks/rules-of-hooks
const changeIndex = React.useMemo<number>(() => {
return this.editor.environment.isSafari ? (globalRenderIndex += 1) : 0
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape])
if (!info?.isValid) return null
const strokeWidth = STROKE_SIZES[shape.props.size]
const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth)
const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth)
const path = info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info)
let handlePath: null | React.JSX.Element = null
if (onlySelectedShape === shape && shouldDisplayHandles) {
const sw = 2
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(this.getLength(shape), sw, {
end: 'skip',
start: 'skip',
lengthRatio: 2.5,
})
handlePath =
shape.props.start.type === 'binding' || shape.props.end.type === 'binding' ? (
<path
className="tl-arrow-hint"
d={info.isStraight ? getStraightArrowHandlePath(info) : getCurvedArrowHandlePath(info)}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeWidth={sw}
markerStart={
shape.props.start.type === 'binding'
? shape.props.start.isExact
? ''
: shape.props.start.isPrecise
? 'url(#arrowhead-cross)'
: 'url(#arrowhead-dot)'
: ''
}
markerEnd={
shape.props.end.type === 'binding'
? shape.props.end.isExact
? ''
: shape.props.end.isPrecise
? 'url(#arrowhead-cross)'
: 'url(#arrowhead-dot)'
: ''
}
opacity={0.16}
/>
) : null
}
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
info.isStraight ? info.length : Math.abs(info.bodyArc.length),
strokeWidth,
{
style: shape.props.dash,
}
)
const labelPosition = getArrowLabelPosition(this.editor, shape)
const maskStartArrowhead = !(
info.start.arrowhead === 'none' || info.start.arrowhead === 'arrow'
)
const maskEndArrowhead = !(info.end.arrowhead === 'none' || info.end.arrowhead === 'arrow')
// NOTE: I know right setting `changeIndex` hacky-as right! But we need this because otherwise safari loses
// the mask, see <https://linear.app/tldraw/issue/TLD-1500/changing-arrow-color-makes-line-pass-through-text>
const maskId = (shape.id + '_clip_' + changeIndex).replace(':', '_')
return (
<>
<SVGContainer id={shape.id} style={{ minWidth: 50, minHeight: 50 }}>
{/* Yep */}
<defs>
<mask id={maskId}>
<rect
x={toDomPrecision(-100 + bounds.minX)}
y={toDomPrecision(-100 + bounds.minY)}
width={toDomPrecision(bounds.width + 200)}
height={toDomPrecision(bounds.height + 200)}
fill="white"
/>
{shape.props.text.trim() && (
<rect
x={labelPosition.box.x}
y={labelPosition.box.y}
width={labelPosition.box.w}
height={labelPosition.box.h}
fill="black"
rx={4}
ry={4}
/>
)}
{as && maskStartArrowhead && (
<path
d={as}
fill={info.start.arrowhead === 'arrow' ? 'none' : 'black'}
stroke="none"
/>
)}
{ae && maskEndArrowhead && (
<path
d={ae}
fill={info.end.arrowhead === 'arrow' ? 'none' : 'black'}
stroke="none"
/>
)}
</mask>
</defs>
<g
fill="none"
stroke={theme[shape.props.color].solid}
strokeWidth={strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="none"
>
{handlePath}
{/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */}
<g mask={`url(#${maskId})`}>
<rect
x={toDomPrecision(bounds.minX - 100)}
y={toDomPrecision(bounds.minY - 100)}
width={toDomPrecision(bounds.width + 200)}
height={toDomPrecision(bounds.height + 200)}
opacity={0}
/>
<path
d={path}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
</g>
{as && maskStartArrowhead && shape.props.fill !== 'none' && (
<ShapeFill theme={theme} d={as} color={shape.props.color} fill={shape.props.fill} />
)}
{ae && maskEndArrowhead && shape.props.fill !== 'none' && (
<ShapeFill theme={theme} d={ae} color={shape.props.color} fill={shape.props.fill} />
)}
{as && <path d={as} />}
{ae && <path d={ae} />}
</g>
<ArrowSvg
shape={shape}
shouldDisplayHandles={shouldDisplayHandles && onlySelectedShape === shape}
/>
</SVGContainer>
<ArrowTextLabel
id={shape.id}
@ -840,177 +689,24 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
}
override toSvg(shape: TLArrowShape, ctx: SvgExportContext) {
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
ctx.addExportDef(getFillDefForExport(shape.props.fill, theme))
ctx.addExportDef(getFillDefForExport(shape.props.fill))
if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font))
const color = theme[shape.props.color].solid
const info = this.editor.getArrowInfo(shape)
const strokeWidth = STROKE_SIZES[shape.props.size]
// Group for arrow
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
if (!info) return g
// Arrowhead start path
const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth)
// Arrowhead end path
const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth)
const geometry = this.editor.getShapeGeometry<Group2d>(shape)
const bounds = geometry.bounds
const labelGeometry = shape.props.text.trim() ? (geometry.children[1] as Rectangle2d) : null
const maskId = (shape.id + '_clip').replace(':', '_')
// If we have any arrowheads, then mask the arrowheads
if (as || ae || !!labelGeometry) {
// Create mask for arrowheads
// Create defs
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
// Create mask
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'mask')
mask.id = maskId
// Create large white shape for mask
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect.setAttribute('x', bounds.minX - 100 + '')
rect.setAttribute('y', bounds.minY - 100 + '')
rect.setAttribute('width', bounds.width + 200 + '')
rect.setAttribute('height', bounds.height + 200 + '')
rect.setAttribute('fill', 'white')
mask.appendChild(rect)
// add arrowhead start mask
if (as) mask.appendChild(getArrowheadSvgMask(as, info.start.arrowhead))
// add arrowhead end mask
if (ae) mask.appendChild(getArrowheadSvgMask(ae, info.end.arrowhead))
// Mask out text label if text is present
if (labelGeometry) {
const labelMask = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
labelMask.setAttribute('x', labelGeometry.x + '')
labelMask.setAttribute('y', labelGeometry.y + '')
labelMask.setAttribute('width', labelGeometry.w + '')
labelMask.setAttribute('height', labelGeometry.h + '')
labelMask.setAttribute('fill', 'black')
mask.appendChild(labelMask)
}
defs.appendChild(mask)
g.appendChild(defs)
}
const g2 = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g2.setAttribute('mask', `url(#${maskId})`)
g.appendChild(g2)
// Dumb mask fix thing
const rect2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect2.setAttribute('x', '-100')
rect2.setAttribute('y', '-100')
rect2.setAttribute('width', bounds.width + 200 + '')
rect2.setAttribute('height', bounds.height + 200 + '')
rect2.setAttribute('fill', 'transparent')
rect2.setAttribute('stroke', 'none')
g2.appendChild(rect2)
// Arrowhead body path
const path = getArrowSvgPath(
info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info),
color,
strokeWidth
return (
<>
<ArrowSvg shape={shape} shouldDisplayHandles={false} />
<SvgTextLabel
fontSize={ARROW_LABEL_FONT_SIZES[shape.props.size]}
font={shape.props.font}
align="middle"
verticalAlign="middle"
text={shape.props.text}
labelColor={shape.props.labelColor}
bounds={getArrowLabelPosition(this.editor, shape).box}
padding={4}
/>
</>
)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
info.isStraight ? info.length : Math.abs(info.bodyArc.length),
strokeWidth,
{
style: shape.props.dash,
}
)
path.setAttribute('stroke-dasharray', strokeDasharray)
path.setAttribute('stroke-dashoffset', strokeDashoffset)
g2.appendChild(path)
// Arrowhead start path
if (as) {
g.appendChild(
getArrowheadSvgPath(
as,
shape.props.color,
strokeWidth,
shape.props.arrowheadStart === 'arrow' ? 'none' : shape.props.fill,
theme
)
)
}
// Arrowhead end path
if (ae) {
g.appendChild(
getArrowheadSvgPath(
ae,
shape.props.color,
strokeWidth,
shape.props.arrowheadEnd === 'arrow' ? 'none' : shape.props.fill,
theme
)
)
}
// Text Label
if (labelGeometry) {
ctx.addExportDef(getFontDefForExport(shape.props.font))
const opts = {
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
lineHeight: TEXT_PROPS.lineHeight,
fontFamily: DefaultFontFamilies[shape.props.font],
padding: 0,
textAlign: 'middle' as const,
width: labelGeometry.w - 8,
verticalTextAlign: 'middle' as const,
height: labelGeometry.h,
fontStyle: 'normal',
fontWeight: 'normal',
overflow: 'wrap' as const,
}
const textElm = createTextSvgElementFromSpans(
this.editor,
this.editor.textMeasure.measureTextSpans(shape.props.text, opts),
opts
)
textElm.setAttribute('fill', theme[shape.props.labelColor].solid)
const children = Array.from(textElm.children) as unknown as SVGTSpanElement[]
children.forEach((child) => {
const x = parseFloat(child.getAttribute('x') || '0')
const y = parseFloat(child.getAttribute('y') || '0')
child.setAttribute('x', x + 4 + labelGeometry.x + 'px')
child.setAttribute('y', y + labelGeometry.y + 'px')
})
const textBgEl = textElm.cloneNode(true) as SVGTextElement
textBgEl.setAttribute('stroke-width', '2')
textBgEl.setAttribute('fill', theme.background)
textBgEl.setAttribute('stroke', theme.background)
g.appendChild(textBgEl)
g.appendChild(textElm)
}
return g
}
override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
@ -1028,55 +724,165 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
}
}
function getArrowheadSvgMask(d: string, arrowhead: TLArrowShapeArrowheadStyle) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
path.setAttribute('fill', arrowhead === 'arrow' ? 'none' : 'black')
path.setAttribute('stroke', 'none')
return path
function getLength(editor: Editor, shape: TLArrowShape): number {
const info = editor.getArrowInfo(shape)!
return info.isStraight
? Vec.Dist(info.start.handle, info.end.handle)
: Math.abs(info.handleArc.length)
}
function getArrowSvgPath(d: string, color: string, strokeWidth: number) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
path.setAttribute('fill', 'none')
path.setAttribute('stroke', color)
path.setAttribute('stroke-width', strokeWidth + '')
return path
}
const ArrowSvg = track(function ArrowSvg({
shape,
shouldDisplayHandles,
}: {
shape: TLArrowShape
shouldDisplayHandles: boolean
}) {
const editor = useEditor()
const theme = useDefaultColorTheme()
const info = editor.getArrowInfo(shape)
const bounds = Box.ZeroFix(editor.getShapeGeometry(shape).bounds)
function getArrowheadSvgPath(
d: string,
color: TLDefaultColorStyle,
strokeWidth: number,
fill: TLDefaultFillStyle,
theme: TLDefaultColorTheme
) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
path.setAttribute('fill', 'none')
path.setAttribute('stroke', theme[color].solid)
path.setAttribute('stroke-width', strokeWidth + '')
const changeIndex = React.useMemo<number>(() => {
return editor.environment.isSafari ? (globalRenderIndex += 1) : 0
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape])
// Get the fill element, if any
const shapeFill = getShapeFillSvg({
d,
fill,
color,
theme,
})
if (!info?.isValid) return null
if (shapeFill) {
// If there is a fill element, return a group containing the fill and the path
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.appendChild(shapeFill)
g.appendChild(path)
return g
} else {
// Otherwise, just return the path
return path
const strokeWidth = STROKE_SIZES[shape.props.size]
const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth)
const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth)
const path = info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info)
let handlePath: null | React.JSX.Element = null
if (shouldDisplayHandles) {
const sw = 2
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
getLength(editor, shape),
sw,
{
end: 'skip',
start: 'skip',
lengthRatio: 2.5,
}
)
handlePath =
shape.props.start.type === 'binding' || shape.props.end.type === 'binding' ? (
<path
className="tl-arrow-hint"
d={info.isStraight ? getStraightArrowHandlePath(info) : getCurvedArrowHandlePath(info)}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeWidth={sw}
markerStart={
shape.props.start.type === 'binding'
? shape.props.start.isExact
? ''
: shape.props.start.isPrecise
? 'url(#arrowhead-cross)'
: 'url(#arrowhead-dot)'
: ''
}
markerEnd={
shape.props.end.type === 'binding'
? shape.props.end.isExact
? ''
: shape.props.end.isPrecise
? 'url(#arrowhead-cross)'
: 'url(#arrowhead-dot)'
: ''
}
opacity={0.16}
/>
) : null
}
}
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
info.isStraight ? info.length : Math.abs(info.bodyArc.length),
strokeWidth,
{
style: shape.props.dash,
}
)
const labelPosition = getArrowLabelPosition(editor, shape)
const maskStartArrowhead = !(info.start.arrowhead === 'none' || info.start.arrowhead === 'arrow')
const maskEndArrowhead = !(info.end.arrowhead === 'none' || info.end.arrowhead === 'arrow')
// NOTE: I know right setting `changeIndex` hacky-as right! But we need this because otherwise safari loses
// the mask, see <https://linear.app/tldraw/issue/TLD-1500/changing-arrow-color-makes-line-pass-through-text>
const maskId = (shape.id + '_clip_' + changeIndex).replace(':', '_')
return (
<>
{/* Yep */}
<defs>
<mask id={maskId}>
<rect
x={toDomPrecision(-100 + bounds.minX)}
y={toDomPrecision(-100 + bounds.minY)}
width={toDomPrecision(bounds.width + 200)}
height={toDomPrecision(bounds.height + 200)}
fill="white"
/>
{shape.props.text.trim() && (
<rect
x={labelPosition.box.x}
y={labelPosition.box.y}
width={labelPosition.box.w}
height={labelPosition.box.h}
fill="black"
rx={4}
ry={4}
/>
)}
{as && maskStartArrowhead && (
<path d={as} fill={info.start.arrowhead === 'arrow' ? 'none' : 'black'} stroke="none" />
)}
{ae && maskEndArrowhead && (
<path d={ae} fill={info.end.arrowhead === 'arrow' ? 'none' : 'black'} stroke="none" />
)}
</mask>
</defs>
<g
fill="none"
stroke={theme[shape.props.color].solid}
strokeWidth={strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="none"
>
{handlePath}
{/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */}
<g mask={`url(#${maskId})`}>
<rect
x={toDomPrecision(bounds.minX - 100)}
y={toDomPrecision(bounds.minY - 100)}
width={toDomPrecision(bounds.width + 200)}
height={toDomPrecision(bounds.height + 200)}
opacity={0}
/>
<path d={path} strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} />
</g>
{as && maskStartArrowhead && shape.props.fill !== 'none' && (
<ShapeFill theme={theme} d={as} color={shape.props.color} fill={shape.props.fill} />
)}
{ae && maskEndArrowhead && shape.props.fill !== 'none' && (
<ShapeFill theme={theme} d={ae} color={shape.props.color} fill={shape.props.fill} />
)}
{as && <path d={as} />}
{ae && <path d={ae} />}
</g>
</>
)
})
const shapeAtTranslationStart = new WeakMap<
TLArrowShape,

View file

@ -27,7 +27,7 @@ import {
const labelSizeCache = new WeakMap<TLArrowShape, Vec>()
export function getArrowLabelSize(editor: Editor, shape: TLArrowShape) {
function getArrowLabelSize(editor: Editor, shape: TLArrowShape) {
const cachedSize = labelSizeCache.get(shape)
if (cachedSize) return cachedSize

View file

@ -14,18 +14,14 @@ import {
VecLike,
drawShapeMigrations,
drawShapeProps,
getDefaultColorTheme,
getSvgPathFromPoints,
last,
rng,
toFixed,
} from '@tldraw/editor'
import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../shared/ShapeFill'
import { STROKE_SIZES } from '../shared/default-shape-constants'
import { getFillDefForCanvas, getFillDefForExport } from '../shared/defaultStyleDefs'
import { getStrokeOutlinePoints } from '../shared/freehand/getStrokeOutlinePoints'
import { getStrokePoints } from '../shared/freehand/getStrokePoints'
import { setStrokePointRadii } from '../shared/freehand/setStrokePointRadii'
import { getSvgPathFromStrokePoints } from '../shared/freehand/svg'
import { svgInk } from '../shared/freehand/svgInk'
import { useForceSolid } from '../shared/useForceSolid'
@ -91,71 +87,9 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
}
component(shape: TLDrawShape) {
const theme = useDefaultColorTheme()
const forceSolid = useForceSolid()
const strokeWidth = STROKE_SIZES[shape.props.size]
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
let sw = strokeWidth
if (
!forceSolid &&
!shape.props.isPen &&
shape.props.dash === 'draw' &&
allPointsFromSegments.length === 1
) {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getFreehandOptions(shape.props, sw, showAsComplete, forceSolid)
if (!forceSolid && shape.props.dash === 'draw') {
return (
<SVGContainer id={shape.id}>
{shape.props.isClosed && shape.props.fill && allPointsFromSegments.length > 1 ? (
<ShapeFill
theme={theme}
fill={shape.props.isClosed ? shape.props.fill : 'none'}
color={shape.props.color}
d={getSvgPathFromStrokePoints(
getStrokePoints(allPointsFromSegments, options),
shape.props.isClosed
)}
/>
) : null}
<path
d={svgInk(allPointsFromSegments, options)}
strokeLinecap="round"
fill={theme[shape.props.color].solid}
/>
</SVGContainer>
)
}
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const isDot = strokePoints.length < 2
const solidStrokePath = isDot
? getDot(allPointsFromSegments[0], 0)
: getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed)
return (
<SVGContainer id={shape.id}>
<ShapeFill
theme={theme}
color={shape.props.color}
fill={isDot || shape.props.isClosed ? shape.props.fill : 'none'}
d={solidStrokePath}
/>
<path
d={solidStrokePath}
strokeLinecap="round"
fill={isDot ? theme[shape.props.color].solid : 'none'}
stroke={theme[shape.props.color].solid}
strokeWidth={strokeWidth}
strokeDasharray={isDot ? 'none' : getDrawShapeStrokeDashArray(shape, strokeWidth)}
strokeDashoffset="0"
/>
<DrawShapSvg shape={shape} forceSolid={useForceSolid()} />
</SVGContainer>
)
}
@ -187,68 +121,8 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
}
override toSvg(shape: TLDrawShape, ctx: SvgExportContext) {
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
ctx.addExportDef(getFillDefForExport(shape.props.fill, theme))
const { color } = shape.props
const strokeWidth = STROKE_SIZES[shape.props.size]
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
let sw = strokeWidth
if (!shape.props.isPen && shape.props.dash === 'draw' && allPointsFromSegments.length === 1) {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getFreehandOptions(shape.props, sw, showAsComplete, false)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1
? getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed)
: getDot(allPointsFromSegments[0], sw)
let foregroundPath: SVGPathElement | undefined
if (shape.props.dash === 'draw' || strokePoints.length < 2) {
setStrokePointRadii(strokePoints, options)
const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options)
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
p.setAttribute('d', getSvgPathFromPoints(strokeOutlinePoints, true))
p.setAttribute('fill', theme[color].solid)
p.setAttribute('stroke-linecap', 'round')
foregroundPath = p
} else {
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
p.setAttribute('d', solidStrokePath)
p.setAttribute('stroke', theme[color].solid)
p.setAttribute('fill', 'none')
p.setAttribute('stroke-linecap', 'round')
p.setAttribute('stroke-width', strokeWidth.toString())
p.setAttribute('stroke-dasharray', getDrawShapeStrokeDashArray(shape, strokeWidth))
p.setAttribute('stroke-dashoffset', '0')
foregroundPath = p
}
const fillPath = getShapeFillSvg({
fill: shape.props.isClosed ? shape.props.fill : 'none',
d: solidStrokePath,
color: shape.props.color,
theme,
})
if (fillPath) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.appendChild(fillPath)
g.appendChild(foregroundPath)
return g
}
return foregroundPath
ctx.addExportDef(getFillDefForExport(shape.props.fill))
return <DrawShapSvg shape={shape} forceSolid={false} />
}
override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
@ -296,3 +170,72 @@ function getDot(point: VecLike, sw: number) {
function getIsDot(shape: TLDrawShape) {
return shape.props.segments.length === 1 && shape.props.segments[0].points.length < 2
}
function DrawShapSvg({ shape, forceSolid }: { shape: TLDrawShape; forceSolid: boolean }) {
const theme = useDefaultColorTheme()
const strokeWidth = STROKE_SIZES[shape.props.size]
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
let sw = strokeWidth
if (
!forceSolid &&
!shape.props.isPen &&
shape.props.dash === 'draw' &&
allPointsFromSegments.length === 1
) {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getFreehandOptions(shape.props, sw, showAsComplete, forceSolid)
if (!forceSolid && shape.props.dash === 'draw') {
return (
<>
{shape.props.isClosed && shape.props.fill && allPointsFromSegments.length > 1 ? (
<ShapeFill
theme={theme}
fill={shape.props.isClosed ? shape.props.fill : 'none'}
color={shape.props.color}
d={getSvgPathFromStrokePoints(
getStrokePoints(allPointsFromSegments, options),
shape.props.isClosed
)}
/>
) : null}
<path
d={svgInk(allPointsFromSegments, options)}
strokeLinecap="round"
fill={theme[shape.props.color].solid}
/>
</>
)
}
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const isDot = strokePoints.length < 2
const solidStrokePath = isDot
? getDot(allPointsFromSegments[0], 0)
: getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed)
return (
<>
<ShapeFill
theme={theme}
color={shape.props.color}
fill={isDot || shape.props.isClosed ? shape.props.fill : 'none'}
d={solidStrokePath}
/>
<path
d={solidStrokePath}
strokeLinecap="round"
fill={isDot ? theme[shape.props.color].solid : 'none'}
stroke={theme[shape.props.color].solid}
strokeWidth={strokeWidth}
strokeDasharray={isDot ? 'none' : getDrawShapeStrokeDashArray(shape, strokeWidth)}
strokeDashoffset="0"
/>
</>
)
}

View file

@ -12,6 +12,7 @@ import {
TLShape,
TLShapeId,
canonicalizeRotation,
exhaustiveSwitchError,
frameShapeMigrations,
frameShapeProps,
getDefaultColorTheme,
@ -22,7 +23,7 @@ import {
} from '@tldraw/editor'
import classNames from 'classnames'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { createTextJsxFromSpans } from '../shared/createTextJsxFromSpans'
import { FrameHeading } from './components/FrameHeading'
export function defaultEmptyAs(str: string, dflt: string) {
@ -97,19 +98,8 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
)
}
override toSvg(shape: TLFrameShape, ctx: SvgExportContext): SVGElement | Promise<SVGElement> {
override toSvg(shape: TLFrameShape, ctx: SvgExportContext) {
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect.setAttribute('width', shape.props.w.toString())
rect.setAttribute('height', shape.props.h.toString())
rect.setAttribute('fill', theme.solid)
rect.setAttribute('stroke', theme.black.solid)
rect.setAttribute('stroke-width', '1')
rect.setAttribute('rx', '1')
rect.setAttribute('ry', '1')
g.appendChild(rect)
// Text label
const pageRotation = canonicalizeRotation(
@ -128,18 +118,18 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
labelTranslate = ``
break
case 'right':
labelTranslate = `translate(${toDomPrecision(shape.props.w)}px, 0px) rotate(90deg)`
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, 0) rotate(90)`
break
case 'bottom':
labelTranslate = `translate(${toDomPrecision(shape.props.w)}px, ${toDomPrecision(
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, ${toDomPrecision(
shape.props.h
)}px) rotate(180deg)`
)}) rotate(180)`
break
case 'left':
labelTranslate = `translate(0px, ${toDomPrecision(shape.props.h)}px) rotate(270deg)`
labelTranslate = `translate(0, ${toDomPrecision(shape.props.h)}) rotate(270)`
break
default:
labelTranslate = ``
exhaustiveSwitchError(labelSide)
}
// Truncate with ellipsis
@ -165,25 +155,36 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
const firstSpan = spans[0]
const lastSpan = last(spans)!
const labelTextWidth = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x
const text = createTextSvgElementFromSpans(this.editor, spans, {
const text = createTextJsxFromSpans(this.editor, spans, {
offsetY: -opts.height - 2,
...opts,
})
text.style.setProperty('transform', labelTranslate)
const textBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
textBg.setAttribute('x', '-8px')
textBg.setAttribute('y', -opts.height - 4 + 'px')
textBg.setAttribute('width', labelTextWidth + 16 + 'px')
textBg.setAttribute('height', `${opts.height}px`)
textBg.setAttribute('rx', 4 + 'px')
textBg.setAttribute('ry', 4 + 'px')
textBg.setAttribute('fill', theme.background)
g.appendChild(textBg)
g.appendChild(text)
return g
return (
<>
<rect
width={shape.props.w}
height={shape.props.h}
fill={theme.solid}
stroke={theme.black.solid}
strokeWidth={1}
rx={1}
ry={1}
/>
<g transform={labelTranslate}>
<rect
x={-8}
y={-opts.height - 4}
width={labelTextWidth + 16}
height={opts.height}
fill={theme.background}
rx={4}
ry={4}
/>
{text}
</g>
</>
)
}
indicator(shape: TLFrameShape) {

View file

@ -1,6 +1,5 @@
import {
BaseBoxShapeUtil,
DefaultFontFamilies,
Editor,
Ellipse2d,
Geometry2d,
@ -15,21 +14,19 @@ import {
SVGContainer,
Stadium2d,
SvgExportContext,
TLDefaultDashStyle,
TLGeoShape,
TLOnEditEndHandler,
TLOnResizeHandler,
TLShapeUtilCanvasSvgDef,
Vec,
VecLike,
exhaustiveSwitchError,
geoShapeMigrations,
geoShapeProps,
getDefaultColorTheme,
getPolygonVertices,
} from '@tldraw/editor'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import {
FONT_FAMILIES,
@ -42,24 +39,12 @@ import {
getFillDefForExport,
getFontDefForExport,
} from '../shared/defaultStyleDefs'
import { getTextLabelSvgElement } from '../shared/getTextLabelSvgElement'
import { getRoundedInkyPolygonPath, getRoundedPolygonPoints } from '../shared/polygon-helpers'
import { cloudOutline, cloudSvgPath } from './cloudOutline'
import { DashStyleCloud, DashStyleCloudSvg } from './components/DashStyleCloud'
import { DashStyleEllipse, DashStyleEllipseSvg } from './components/DashStyleEllipse'
import { DashStyleOval, DashStyleOvalSvg } from './components/DashStyleOval'
import { DashStylePolygon, DashStylePolygonSvg } from './components/DashStylePolygon'
import { DrawStyleCloud, DrawStyleCloudSvg } from './components/DrawStyleCloud'
import { DrawStyleEllipseSvg, getEllipseIndicatorPath } from './components/DrawStyleEllipse'
import { DrawStylePolygon, DrawStylePolygonSvg } from './components/DrawStylePolygon'
import { SolidStyleCloud, SolidStyleCloudSvg } from './components/SolidStyleCloud'
import { SolidStyleEllipse, SolidStyleEllipseSvg } from './components/SolidStyleEllipse'
import {
SolidStyleOval,
SolidStyleOvalSvg,
getOvalIndicatorPath,
} from './components/SolidStyleOval'
import { SolidStylePolygon, SolidStylePolygonSvg } from './components/SolidStylePolygon'
import { getEllipseIndicatorPath } from './components/DrawStyleEllipse'
import { GeoShapeBody } from './components/GeoShapeBody'
import { getOvalIndicatorPath } from './components/SolidStyleOval'
import { getLines } from './getLines'
const LABEL_PADDING = 16
const MIN_SIZE_WITH_LABEL = 17 * 3
@ -396,152 +381,13 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
component(shape: TLGeoShape) {
const { id, type, props } = shape
const strokeWidth = STROKE_SIZES[props.size]
const { w, color, labelColor, fill, dash, growY, font, align, verticalAlign, size, text } =
props
const getShape = () => {
const h = props.h + growY
switch (props.geo) {
case 'cloud': {
if (dash === 'solid') {
return (
<SolidStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
/>
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
dash={dash}
/>
)
} else if (dash === 'draw') {
return (
<DrawStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
/>
)
}
break
}
case 'ellipse': {
if (dash === 'solid') {
return (
<SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleEllipse
id={id}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash}
color={color}
fill={fill}
/>
)
} else if (dash === 'draw') {
return (
<SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
}
break
}
case 'oval': {
if (dash === 'solid') {
return (
<SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleOval
id={id}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash}
color={color}
fill={fill}
/>
)
} else if (dash === 'draw') {
return (
<SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
}
break
}
default: {
const geometry = this.editor.getShapeGeometry(shape)
const outline =
geometry instanceof Group2d ? geometry.children[0].vertices : geometry.vertices
const lines = getLines(shape.props, strokeWidth)
if (dash === 'solid') {
return (
<SolidStylePolygon
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStylePolygon
dash={dash}
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
} else if (dash === 'draw') {
return (
<DrawStylePolygon
id={id}
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
}
}
}
}
const { labelColor, fill, font, align, verticalAlign, size, text } = props
return (
<>
<SVGContainer id={id}>{getShape()}</SVGContainer>
<SVGContainer id={id}>
<GeoShapeBody shape={shape} />
</SVGContainer>
<HTMLContainer
id={shape.id}
style={{ overflow: 'hidden', width: shape.props.w, height: shape.props.h + props.growY }}
@ -616,222 +462,33 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
}
override toSvg(shape: TLGeoShape, ctx: SvgExportContext) {
const { id, props } = shape
const strokeWidth = STROKE_SIZES[props.size]
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
ctx.addExportDef(getFillDefForExport(shape.props.fill, theme))
let svgElm: SVGElement
switch (props.geo) {
case 'ellipse': {
switch (props.dash) {
case 'draw':
svgElm = DrawStyleEllipseSvg({
id,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
strokeWidth,
theme,
})
break
case 'solid':
svgElm = SolidStyleEllipseSvg({
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
theme,
})
break
default:
svgElm = DashStyleEllipseSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
theme,
})
break
}
break
}
case 'oval': {
switch (props.dash) {
case 'draw':
svgElm = DashStyleOvalSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
theme,
})
break
case 'solid':
svgElm = SolidStyleOvalSvg({
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
theme,
})
break
default:
svgElm = DashStyleOvalSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
theme,
})
}
break
}
case 'cloud': {
switch (props.dash) {
case 'draw':
svgElm = DrawStyleCloudSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
size: props.size,
theme,
})
break
case 'solid':
svgElm = SolidStyleCloudSvg({
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
size: props.size,
id,
theme,
})
break
default:
svgElm = DashStyleCloudSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
theme,
size: props.size,
})
}
break
}
default: {
const geometry = this.editor.getShapeGeometry(shape)
const outline =
geometry instanceof Group2d ? geometry.children[0].vertices : geometry.vertices
const lines = getLines(shape.props, strokeWidth)
switch (props.dash) {
case 'draw':
svgElm = DrawStylePolygonSvg({
id,
fill: props.fill,
color: props.color,
strokeWidth,
outline,
lines,
theme,
})
break
case 'solid':
svgElm = SolidStylePolygonSvg({
fill: props.fill,
color: props.color,
strokeWidth,
outline,
lines,
theme,
})
break
default:
svgElm = DashStylePolygonSvg({
dash: props.dash,
fill: props.fill,
color: props.color,
strokeWidth,
outline,
lines,
theme,
})
break
}
break
}
}
const { props } = shape
ctx.addExportDef(getFillDefForExport(shape.props.fill))
let textEl
if (props.text) {
const bounds = this.editor.getShapeGeometry(shape).bounds
ctx.addExportDef(getFontDefForExport(shape.props.font))
const rootTextElm = getTextLabelSvgElement({
editor: this.editor,
shape,
font: DefaultFontFamilies[shape.props.font],
bounds,
})
const textElm = rootTextElm.cloneNode(true) as SVGTextElement
textElm.setAttribute('fill', theme[shape.props.labelColor].solid)
textElm.setAttribute('stroke', 'none')
const textBgEl = rootTextElm.cloneNode(true) as SVGTextElement
textBgEl.setAttribute('stroke-width', '2')
textBgEl.setAttribute('fill', theme.background)
textBgEl.setAttribute('stroke', theme.background)
const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
groupEl.append(textBgEl)
groupEl.append(textElm)
if (svgElm.nodeName === 'g') {
svgElm.appendChild(groupEl)
return svgElm
} else {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.appendChild(svgElm)
g.appendChild(groupEl)
return g
}
const bounds = this.editor.getShapeGeometry(shape).bounds
textEl = (
<SvgTextLabel
fontSize={LABEL_FONT_SIZES[props.size]}
font={props.font}
align={props.align}
verticalAlign={props.verticalAlign}
text={props.text}
labelColor={props.labelColor}
bounds={bounds}
/>
)
}
return svgElm
return (
<>
<GeoShapeBody shape={shape} />
{textEl}
</>
)
}
override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
@ -1110,80 +767,3 @@ function getLabelSize(editor: Editor, shape: TLGeoShape) {
h: size.h + LABEL_PADDING * 2,
}
}
function getLines(props: TLGeoShape['props'], sw: number) {
switch (props.geo) {
case 'x-box': {
return getXBoxLines(props.w, props.h, sw, props.dash)
}
case 'check-box': {
return getCheckBoxLines(props.w, props.h)
}
default: {
return undefined
}
}
}
function getXBoxLines(w: number, h: number, sw: number, dash: TLDefaultDashStyle) {
const inset = dash === 'draw' ? 0.62 : 0
if (dash === 'dashed') {
return [
[new Vec(0, 0), new Vec(w / 2, h / 2)],
[new Vec(w, h), new Vec(w / 2, h / 2)],
[new Vec(0, h), new Vec(w / 2, h / 2)],
[new Vec(w, 0), new Vec(w / 2, h / 2)],
]
}
const clampX = (x: number) => Math.max(0, Math.min(w, x))
const clampY = (y: number) => Math.max(0, Math.min(h, y))
return [
[
new Vec(clampX(sw * inset), clampY(sw * inset)),
new Vec(clampX(w - sw * inset), clampY(h - sw * inset)),
],
[
new Vec(clampX(sw * inset), clampY(h - sw * inset)),
new Vec(clampX(w - sw * inset), clampY(sw * inset)),
],
]
}
function getCheckBoxLines(w: number, h: number) {
const size = Math.min(w, h) * 0.82
const ox = (w - size) / 2
const oy = (h - size) / 2
const clampX = (x: number) => Math.max(0, Math.min(w, x))
const clampY = (y: number) => Math.max(0, Math.min(h, y))
return [
[
new Vec(clampX(ox + size * 0.25), clampY(oy + size * 0.52)),
new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)),
],
[
new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)),
new Vec(clampX(ox + size * 0.82), clampY(oy + size * 0.22)),
],
]
}
/**
* Get the centroid of a regular polygon.
* @param points - The points that make up the polygon.
* @internal
*/
export function getCentroidOfRegularPolygon(points: VecLike[]) {
const len = points.length
let x = 0
let y = 0
for (let i = 0; i < len; i++) {
x += points[i].x
y += points[i].y
}
return new Vec(x / len, y / len)
}

View file

@ -28,7 +28,7 @@ type PillSection =
startAngle: number
}
export function getPillPoints(width: number, height: number, numPoints: number) {
function getPillPoints(width: number, height: number, numPoints: number) {
const radius = Math.min(width, height) / 2
const longSide = Math.max(width, height) - radius * 2
@ -352,7 +352,7 @@ export function inkyCloudSvgPath(
return pathA + pathB + ' Z'
}
export function pointsOnArc(
function pointsOnArc(
startPoint: VecModel,
endPoint: VecModel,
center: VecModel | null,

View file

@ -1,17 +1,6 @@
import {
TLDefaultColorTheme,
TLGeoShape,
TLShapeId,
Vec,
canonicalizeRotation,
} from '@tldraw/editor'
import { TLGeoShape, TLShapeId, Vec, canonicalizeRotation } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
import { getPerfectDashProps } from '../../shared/getPerfectDashProps'
import { cloudSvgPath, getCloudArcs } from '../cloudOutline'
@ -72,64 +61,3 @@ export const DashStyleCloud = React.memo(function DashStylePolygon({
</>
)
})
export function DashStyleCloudSvg({
dash,
fill,
color,
theme,
strokeWidth,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'dash' | 'fill' | 'color' | 'w' | 'h' | 'size'> & {
id: TLShapeId
strokeWidth: number
theme: TLDefaultColorTheme
}) {
const innerPath = cloudSvgPath(w, h, id, size)
const arcs = getCloudArcs(w, h, id, size)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
for (const { leftPoint, rightPoint, center, radius } of arcs) {
const arcLength = center
? radius *
canonicalizeRotation(
canonicalizeRotation(Vec.Angle(center, rightPoint)) -
canonicalizeRotation(Vec.Angle(center, leftPoint))
)
: Vec.Dist(leftPoint, rightPoint)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(arcLength, strokeWidth, {
style: dash,
start: 'outset',
end: 'outset',
})
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute(
'd',
center
? `M${leftPoint.x},${leftPoint.y}A${radius},${radius},0,0,1,${rightPoint.x},${rightPoint.y}`
: `M${leftPoint.x},${leftPoint.y}L${rightPoint.x},${rightPoint.y}`
)
path.setAttribute('stroke-dasharray', strokeDasharray.toString())
path.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
strokeElement.appendChild(path)
}
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: innerPath,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -1,17 +1,6 @@
import {
TLDefaultColorTheme,
TLGeoShape,
TLShapeId,
perimeterOfEllipse,
toDomPrecision,
} from '@tldraw/editor'
import { TLGeoShape, TLShapeId, perimeterOfEllipse, toDomPrecision } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
import { getPerfectDashProps } from '../../shared/getPerfectDashProps'
export const DashStyleEllipse = React.memo(function DashStyleEllipse({
@ -62,56 +51,3 @@ export const DashStyleEllipse = React.memo(function DashStyleEllipse({
</>
)
})
export function DashStyleEllipseSvg({
w,
h,
strokeWidth: sw,
dash,
color,
theme,
fill,
}: Pick<TLGeoShape['props'], 'w' | 'h' | 'dash' | 'color' | 'fill'> & {
strokeWidth: number
id: TLShapeId
theme: TLDefaultColorTheme
}) {
const cx = w / 2
const cy = h / 2
const rx = Math.max(0, cx - sw / 2)
const ry = Math.max(0, cy - sw / 2)
const perimeter = perimeterOfEllipse(rx, ry)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
perimeter < 64 ? perimeter * 2 : perimeter,
sw,
{
style: dash,
snap: 4,
closed: true,
}
)
const d = `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0`
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', d)
strokeElement.setAttribute('stroke-width', sw.toString())
strokeElement.setAttribute('width', w.toString())
strokeElement.setAttribute('height', h.toString())
strokeElement.setAttribute('fill', 'none')
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('stroke-dasharray', strokeDasharray)
strokeElement.setAttribute('stroke-dashoffset', strokeDashoffset)
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape, TLShapeId, toDomPrecision } from '@tldraw/editor'
import { TLGeoShape, TLShapeId, toDomPrecision } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
import { getPerfectDashProps } from '../../shared/getPerfectDashProps'
import { getOvalPerimeter, getOvalSolidPath } from '../helpers'
@ -53,50 +48,3 @@ export const DashStyleOval = React.memo(function DashStyleOval({
</>
)
})
export function DashStyleOvalSvg({
w,
h,
strokeWidth: sw,
dash,
color,
theme,
fill,
}: Pick<TLGeoShape['props'], 'w' | 'h' | 'dash' | 'color' | 'fill'> & {
strokeWidth: number
id: TLShapeId
theme: TLDefaultColorTheme
}) {
const d = getOvalSolidPath(w, h)
const perimeter = getOvalPerimeter(w, h)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
perimeter < 64 ? perimeter * 2 : perimeter,
sw,
{
style: dash,
snap: 4,
closed: true,
}
)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', d)
strokeElement.setAttribute('stroke-width', sw.toString())
strokeElement.setAttribute('width', w.toString())
strokeElement.setAttribute('height', h.toString())
strokeElement.setAttribute('fill', 'none')
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('stroke-dasharray', strokeDasharray)
strokeElement.setAttribute('stroke-dashoffset', strokeDashoffset)
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape, Vec, VecLike } from '@tldraw/editor'
import { TLGeoShape, Vec, VecLike } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
import { getPerfectDashProps } from '../../shared/getPerfectDashProps'
export const DashStylePolygon = React.memo(function DashStylePolygon({
@ -78,75 +73,3 @@ export const DashStylePolygon = React.memo(function DashStylePolygon({
</>
)
})
export function DashStylePolygonSvg({
dash,
fill,
color,
theme,
strokeWidth,
outline,
lines,
}: Pick<TLGeoShape['props'], 'dash' | 'fill' | 'color'> & {
outline: VecLike[]
strokeWidth: number
theme: TLDefaultColorTheme
lines?: VecLike[][]
}) {
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
Array.from(Array(outline.length)).forEach((_, i) => {
const A = outline[i]
const B = outline[(i + 1) % outline.length]
const dist = Vec.Dist(A, B)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, {
style: dash,
})
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
line.setAttribute('x1', A.x.toString())
line.setAttribute('y1', A.y.toString())
line.setAttribute('x2', B.x.toString())
line.setAttribute('y2', B.y.toString())
line.setAttribute('stroke-dasharray', strokeDasharray.toString())
line.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
strokeElement.appendChild(line)
})
if (lines) {
for (const [A, B] of lines) {
const dist = Vec.Dist(A, B)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, {
style: dash,
start: 'skip',
end: 'skip',
snap: dash === 'dotted' ? 4 : 2,
})
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
line.setAttribute('x1', A.x.toString())
line.setAttribute('y1', A.y.toString())
line.setAttribute('x2', B.x.toString())
line.setAttribute('y2', B.y.toString())
line.setAttribute('stroke-dasharray', strokeDasharray.toString())
line.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
strokeElement.appendChild(line)
}
}
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: 'M' + outline[0] + 'L' + outline.slice(1) + 'Z',
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape, TLShapeId } from '@tldraw/editor'
import { TLGeoShape, TLShapeId } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
import { inkyCloudSvgPath } from '../cloudOutline'
export const DrawStyleCloud = React.memo(function StyleCloud({
@ -30,36 +25,3 @@ export const DrawStyleCloud = React.memo(function StyleCloud({
</>
)
})
export function DrawStyleCloudSvg({
fill,
color,
strokeWidth,
theme,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
theme: TLDefaultColorTheme
id: TLShapeId
}) {
const pathData = inkyCloudSvgPath(w, h, id, size)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', pathData)
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: pathData,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -1,80 +1,8 @@
import {
EASINGS,
HALF_PI,
PI2,
TLDefaultColorTheme,
TLGeoShape,
TLShapeId,
Vec,
getSvgPathFromPoints,
perimeterOfEllipse,
rng,
} from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { getStrokeOutlinePoints } from '../../shared/freehand/getStrokeOutlinePoints'
import { EASINGS, HALF_PI, PI2, Vec, perimeterOfEllipse, rng } from '@tldraw/editor'
import { getStrokePoints } from '../../shared/freehand/getStrokePoints'
import { setStrokePointRadii } from '../../shared/freehand/setStrokePointRadii'
import { getSvgPathFromStrokePoints } from '../../shared/freehand/svg'
export const DrawStyleEllipse = React.memo(function DrawStyleEllipse({
id,
w,
h,
strokeWidth: sw,
fill,
color,
}: Pick<TLGeoShape['props'], 'w' | 'h' | 'fill' | 'color'> & {
strokeWidth: number
id: TLShapeId
}) {
const theme = useDefaultColorTheme()
const innerPath = getEllipseIndicatorPath(id, w, h, sw)
const outerPath = getEllipsePath(id, w, h, sw)
return (
<>
<ShapeFill theme={theme} d={innerPath} color={color} fill={fill} />
<path d={outerPath} fill={theme[color].solid} strokeWidth={0} pointerEvents="all" />
</>
)
})
export function DrawStyleEllipseSvg({
id,
w,
h,
strokeWidth: sw,
fill,
color,
theme,
}: Pick<TLGeoShape['props'], 'w' | 'h' | 'fill' | 'color'> & {
strokeWidth: number
id: TLShapeId
theme: TLDefaultColorTheme
}) {
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', getEllipsePath(id, w, h, sw))
strokeElement.setAttribute('fill', theme[color].solid)
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: getEllipseIndicatorPath(id, w, h, sw),
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}
export function getEllipseStrokeOptions(strokeWidth: number) {
function getEllipseStrokeOptions(strokeWidth: number) {
return {
size: 1 + strokeWidth,
thinning: 0.25,
@ -86,12 +14,7 @@ export function getEllipseStrokeOptions(strokeWidth: number) {
}
}
export function getEllipseStrokePoints(
id: string,
width: number,
height: number,
strokeWidth: number
) {
function getEllipseStrokePoints(id: string, width: number, height: number, strokeWidth: number) {
const getRandom = rng(id)
const rx = width / 2
@ -125,16 +48,6 @@ export function getEllipseStrokePoints(
return getStrokePoints(points, getEllipseStrokeOptions(strokeWidth))
}
export function getEllipsePath(id: string, width: number, height: number, strokeWidth: number) {
const options = getEllipseStrokeOptions(strokeWidth)
return getSvgPathFromPoints(
getStrokeOutlinePoints(
setStrokePointRadii(getEllipseStrokePoints(id, width, height, strokeWidth), options),
options
)
)
}
export function getEllipseIndicatorPath(
id: string,
width: number,

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape, VecLike } from '@tldraw/editor'
import { TLGeoShape, VecLike } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
import { getRoundedInkyPolygonPath, getRoundedPolygonPoints } from '../../shared/polygon-helpers'
export const DrawStylePolygon = React.memo(function DrawStylePolygon({
@ -41,223 +36,3 @@ export const DrawStylePolygon = React.memo(function DrawStylePolygon({
</>
)
})
export function DrawStylePolygonSvg({
id,
outline,
lines,
fill,
color,
theme,
strokeWidth,
}: Pick<TLGeoShape['props'], 'fill' | 'color'> & {
id: TLGeoShape['id']
outline: VecLike[]
lines?: VecLike[][]
strokeWidth: number
theme: TLDefaultColorTheme
}) {
const polygonPoints = getRoundedPolygonPoints(id, outline, strokeWidth / 3, strokeWidth * 2, 2)
let strokePathData = getRoundedInkyPolygonPath(polygonPoints)
if (lines) {
for (const [A, B] of lines) {
strokePathData += `M${A.x},${A.y}L${B.x},${B.y}`
}
}
const innerPolygonPoints = getRoundedPolygonPoints(id, outline, 0, strokeWidth * 2, 1)
const innerPathData = getRoundedInkyPolygonPath(innerPolygonPoints)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', strokePathData)
strokeElement.setAttribute('fill', 'none')
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: innerPathData,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}
// function getPolygonDrawPoints(id: string, outline: VecLike[], strokeWidth: number) {
// const points: Vec[] = []
// const getRandom = rng(id)
// const start = Math.round(Math.abs(getRandom()) * outline.length)
// const corners = outline.map((p) =>
// Vec.AddXY(p, (getRandom() * strokeWidth) / 4, (getRandom() * strokeWidth) / 4)
// )
// const len = corners.length
// for (let i = 0, n = len + 1; i < n; i++) {
// const At = corners[(start + i) % len]
// const Bt = corners[(start + i + 1) % len]
// const dist = Math.min(Vec.Dist(At, Bt) / 2, strokeWidth / 2)
// const A = Vec.Nudge(At, Bt, dist)
// const D = Vec.Med(At, Bt)
// if (i === 0) {
// Bt.z = 0.7
// points.push(new Vec(D.x, D.y, 0.7), Bt)
// } else if (i === outline.length) {
// const lastSegPoints = Vec.PointsBetween(A, D, 4)
// lastSegPoints.forEach((p) => (p.z = 0.7))
// points.push(...lastSegPoints)
// } else {
// points.push(...Vec.PointsBetween(A, Bt, 6))
// }
// }
// return points
// }
// export function getPolygonIndicatorPath(id: string, outline: VecLike[], strokeWidth: number) {
// const points = getPolygonDrawPoints(id, outline, strokeWidth)
// const options = getPolygonStrokeOptions(strokeWidth)
// const strokePoints = getStrokePoints(points, options)
// return getSvgPathFromStrokePoints(strokePoints, true)
// }
// function getPolygonStrokeOptions(strokeWidth: number) {
// return {
// size: 1 + strokeWidth * 0.618,
// last: true,
// simulatePressure: false,
// streamline: 0.25,
// thinning: 0.9,
// }
// }
// function getPolygonstrokePathData(id: string, outline: VecLike[], strokeWidth: number) {
// // draw a line between all of the points
// let d = `M${outline[0].x},${outline[0].y}`
// d += 'Z'
// for (const { x, y } of outline) {
// d += `${x},${y}`
// }
// return d
// }
// function SimpleInkyPolygon(id: string, outline: VecLike[], offset: number) {
// const random = rng(id)
// let p = outline[0]
// let ox = random() * offset
// let oy = random() * offset
// let polylineA = `M${p.x - ox},${p.y - oy}L`
// let polylineB = `${p.x + ox},${p.y + oy} `
// for (let i = 1, n = outline.length; i < n; i++) {
// p = outline[i]
// ox = random() * offset
// oy = random() * offset
// polylineA += `${p.x - ox},${p.y - oy} `
// polylineB += `${p.x + ox},${p.y + oy} `
// }
// polylineB += 'Z'
// polylineA += polylineB
// return polylineA
// }
// function CubicInkyPolygon(id: string, outline: VecLike[], offset: number) {
// const random = rng(id)
// let p0 = outline[0]
// let p1 = p0
// let ox: number
// let oy: number
// let polylineA = `M${p0.x},${p0.y} L`
// let polylineB = `M${p0.x},${p0.y}`
// for (let i = 0, n = outline.length; i < n; i++) {
// p0 = outline[i]
// p1 = outline[(i + 1) % n]
// polylineA += `${p1.x},${p1.y} `
// ox = random() * offset
// oy = random() * offset
// const c1 = Vec.Lrp(p0, p1, 0.25)
// const c2 = Vec.Lrp(p0, p1, 0.75)
// polylineB += `C${c1.x + ox},${c1.y + oy} ${c2.x - ox},${c2.y - oy} ${p1.x},${p1.y}`
// }
// polylineB += 'Z'
// polylineA += polylineB
// return polylineA
// }
// function QuadraticInkyPolygon(id: string, outline: VecLike[], offset: number) {
// const random = rng(id)
// let p0 = outline[0]
// let p1 = p0
// let polylineA = `M${p0.x},${p0.y} Q`
// const len = outline.length
// for (let i = 0, n = len * 2; i < n; i++) {
// p0 = outline[i % len]
// p1 = outline[(i + 1) % len]
// const dist = Vec.Dist(p0, p1)
// const c1 = Vec.Lrp(p0, p1, 0.5 + random() / 2)
// polylineA += `${c1.x + random() * Math.min(dist / 10, offset)},${
// c1.y + random() * Math.min(dist / 10, offset)
// } ${p1.x + (random() * offset) / 2},${p1.y + (random() * offset) / 2} `
// }
// polylineA += 'Z'
// return polylineA
// }
// function GlobyInkyPolygon(id: string, outline: VecLike[], offset: number) {
// const random = rng(id)
// let p0 = outline[0]
// let p1 = p0
// let polylineA = `M${p0.x},${p0.y} Q`
// const len = outline.length
// for (let i = 0, n = len * 2; i < n; i++) {
// p0 = outline[i % len]
// p1 = outline[(i + 1) % len]
// const dist = Vec.Dist(p0, p1)
// const c1 = Vec.Lrp(p0, p1, 0.5 + random() / 2)
// polylineA += `${c1.x + random() * Math.min(dist / 10, offset)},${
// c1.y + random() * Math.min(dist / 10, offset)
// } ${p1.x + (random() * offset) / 2},${p1.y + (random() * offset) / 2} `
// }
// polylineA += 'Z'
// return polylineA
// }

View file

@ -0,0 +1,146 @@
import { Group2d, TLGeoShape, useEditor } from '@tldraw/editor'
import { STROKE_SIZES } from '../../shared/default-shape-constants'
import { getLines } from '../getLines'
import { DashStyleCloud } from './DashStyleCloud'
import { DashStyleEllipse } from './DashStyleEllipse'
import { DashStyleOval } from './DashStyleOval'
import { DashStylePolygon } from './DashStylePolygon'
import { DrawStyleCloud } from './DrawStyleCloud'
import { DrawStylePolygon } from './DrawStylePolygon'
import { SolidStyleCloud } from './SolidStyleCloud'
import { SolidStyleEllipse } from './SolidStyleEllipse'
import { SolidStyleOval } from './SolidStyleOval'
import { SolidStylePolygon } from './SolidStylePolygon'
export function GeoShapeBody({ shape }: { shape: TLGeoShape }) {
const editor = useEditor()
const { id, props } = shape
const { w, color, fill, dash, growY, size } = props
const strokeWidth = STROKE_SIZES[size]
const h = props.h + growY
switch (props.geo) {
case 'cloud': {
if (dash === 'solid') {
return (
<SolidStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
/>
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
dash={dash}
/>
)
} else if (dash === 'draw') {
return (
<DrawStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
/>
)
}
break
}
case 'ellipse': {
if (dash === 'solid') {
return <SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleEllipse
id={id}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash}
color={color}
fill={fill}
/>
)
} else if (dash === 'draw') {
return <SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
}
break
}
case 'oval': {
if (dash === 'solid') {
return <SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleOval
id={id}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash}
color={color}
fill={fill}
/>
)
} else if (dash === 'draw') {
return <SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
}
break
}
default: {
const geometry = editor.getShapeGeometry(shape)
const outline =
geometry instanceof Group2d ? geometry.children[0].vertices : geometry.vertices
const lines = getLines(shape.props, strokeWidth)
if (dash === 'solid') {
return (
<SolidStylePolygon
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStylePolygon
dash={dash}
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
} else if (dash === 'draw') {
return (
<DrawStylePolygon
id={id}
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
}
}
}
}

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape, TLShapeId } from '@tldraw/editor'
import { TLGeoShape, TLShapeId } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
import { cloudSvgPath } from '../cloudOutline'
export const SolidStyleCloud = React.memo(function SolidStyleCloud({
@ -30,36 +25,3 @@ export const SolidStyleCloud = React.memo(function SolidStyleCloud({
</>
)
})
export function SolidStyleCloudSvg({
fill,
color,
strokeWidth,
theme,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
theme: TLDefaultColorTheme
id: TLShapeId
}) {
const pathData = cloudSvgPath(w, h, id, size)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', pathData)
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: pathData,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape } from '@tldraw/editor'
import { TLGeoShape } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
export const SolidStyleEllipse = React.memo(function SolidStyleEllipse({
w,
@ -29,40 +24,3 @@ export const SolidStyleEllipse = React.memo(function SolidStyleEllipse({
</>
)
})
export function SolidStyleEllipseSvg({
w,
h,
strokeWidth: sw,
fill,
color,
theme,
}: Pick<TLGeoShape['props'], 'w' | 'h' | 'fill' | 'color'> & {
strokeWidth: number
theme: TLDefaultColorTheme
}) {
const cx = w / 2
const cy = h / 2
const rx = Math.max(0, cx)
const ry = Math.max(0, cy)
const d = `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0`
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', d)
strokeElement.setAttribute('stroke-width', sw.toString())
strokeElement.setAttribute('width', w.toString())
strokeElement.setAttribute('height', h.toString())
strokeElement.setAttribute('fill', 'none')
strokeElement.setAttribute('stroke', theme[color].solid)
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape } from '@tldraw/editor'
import { TLGeoShape } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
export const SolidStyleOval = React.memo(function SolidStyleOval({
w,
@ -26,37 +21,6 @@ export const SolidStyleOval = React.memo(function SolidStyleOval({
)
})
export function SolidStyleOvalSvg({
w,
h,
strokeWidth: sw,
fill,
color,
theme,
}: Pick<TLGeoShape['props'], 'w' | 'h' | 'fill' | 'color'> & {
strokeWidth: number
theme: TLDefaultColorTheme
}) {
const d = getOvalIndicatorPath(w, h)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', d)
strokeElement.setAttribute('stroke-width', sw.toString())
strokeElement.setAttribute('width', w.toString())
strokeElement.setAttribute('height', h.toString())
strokeElement.setAttribute('fill', 'none')
strokeElement.setAttribute('stroke', theme[color].solid)
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}
export function getOvalIndicatorPath(w: number, h: number) {
let d: string

View file

@ -1,11 +1,6 @@
import { TLDefaultColorTheme, TLGeoShape, VecLike } from '@tldraw/editor'
import { TLGeoShape, VecLike } from '@tldraw/editor'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill'
export const SolidStylePolygon = React.memo(function SolidStylePolygon({
outline,
@ -34,44 +29,3 @@ export const SolidStylePolygon = React.memo(function SolidStylePolygon({
</>
)
})
export function SolidStylePolygonSvg({
outline,
lines,
fill,
color,
strokeWidth,
theme,
}: Pick<TLGeoShape['props'], 'fill' | 'color'> & {
outline: VecLike[]
strokeWidth: number
theme: TLDefaultColorTheme
lines?: VecLike[][]
}) {
const pathData = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z'
const fillPathData = pathData
let strokePathData = pathData
if (lines) {
for (const [A, B] of lines) {
strokePathData += `M${A.x},${A.y}L${B.x},${B.y}`
}
}
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', strokePathData)
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: fillPathData,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -0,0 +1,60 @@
import { TLDefaultDashStyle, TLGeoShape, Vec } from '@tldraw/editor'
export function getLines(props: TLGeoShape['props'], sw: number) {
switch (props.geo) {
case 'x-box': {
return getXBoxLines(props.w, props.h, sw, props.dash)
}
case 'check-box': {
return getCheckBoxLines(props.w, props.h)
}
default: {
return undefined
}
}
}
function getXBoxLines(w: number, h: number, sw: number, dash: TLDefaultDashStyle) {
const inset = dash === 'draw' ? 0.62 : 0
if (dash === 'dashed') {
return [
[new Vec(0, 0), new Vec(w / 2, h / 2)],
[new Vec(w, h), new Vec(w / 2, h / 2)],
[new Vec(0, h), new Vec(w / 2, h / 2)],
[new Vec(w, 0), new Vec(w / 2, h / 2)],
]
}
const clampX = (x: number) => Math.max(0, Math.min(w, x))
const clampY = (y: number) => Math.max(0, Math.min(h, y))
return [
[
new Vec(clampX(sw * inset), clampY(sw * inset)),
new Vec(clampX(w - sw * inset), clampY(h - sw * inset)),
],
[
new Vec(clampX(sw * inset), clampY(h - sw * inset)),
new Vec(clampX(w - sw * inset), clampY(sw * inset)),
],
]
}
function getCheckBoxLines(w: number, h: number) {
const size = Math.min(w, h) * 0.82
const ox = (w - size) / 2
const oy = (h - size) / 2
const clampX = (x: number) => Math.max(0, Math.min(w, x))
const clampY = (y: number) => Math.max(0, Math.min(h, y))
return [
[
new Vec(clampX(ox + size * 0.25), clampY(oy + size * 0.52)),
new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)),
],
[
new Vec(clampX(ox + size * 0.45), clampY(oy + size * 0.82)),
new Vec(clampX(ox + size * 0.82), clampY(oy + size * 0.22)),
],
]
}

View file

@ -4,13 +4,10 @@ import {
Polygon2d,
SVGContainer,
ShapeUtil,
SvgExportContext,
TLDefaultColorTheme,
TLDrawShapeSegment,
TLHighlightShape,
TLOnResizeHandler,
VecLike,
getDefaultColorTheme,
highlightShapeMigrations,
highlightShapeProps,
last,
@ -72,21 +69,17 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
component(shape: TLHighlightShape) {
return (
<HighlightRenderer
strokeWidth={getStrokeWidth(shape)}
shape={shape}
opacity={OVERLAY_OPACITY}
/>
<SVGContainer id={shape.id} style={{ opacity: OVERLAY_OPACITY }}>
<HighlightRenderer strokeWidth={getStrokeWidth(shape)} shape={shape} />
</SVGContainer>
)
}
override backgroundComponent(shape: TLHighlightShape) {
return (
<HighlightRenderer
strokeWidth={getStrokeWidth(shape)}
shape={shape}
opacity={UNDERLAY_OPACITY}
/>
<SVGContainer id={shape.id} style={{ opacity: UNDERLAY_OPACITY }}>
<HighlightRenderer strokeWidth={getStrokeWidth(shape)} shape={shape} />
</SVGContainer>
)
}
@ -117,14 +110,24 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
return <path d={strokePath} />
}
override toSvg(shape: TLHighlightShape, ctx: SvgExportContext) {
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
return highlighterToSvg(getStrokeWidth(shape), shape, OVERLAY_OPACITY, theme)
override toSvg(shape: TLHighlightShape) {
return (
<HighlightRenderer
strokeWidth={getStrokeWidth(shape)}
shape={shape}
opacity={OVERLAY_OPACITY}
/>
)
}
override toBackgroundSvg(shape: TLHighlightShape) {
const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() })
return highlighterToSvg(getStrokeWidth(shape), shape, UNDERLAY_OPACITY, theme)
return (
<HighlightRenderer
strokeWidth={getStrokeWidth(shape)}
shape={shape}
opacity={UNDERLAY_OPACITY}
/>
)
}
override onResize: TLOnResizeHandler<TLHighlightShape> = (shape, info) => {
@ -216,37 +219,18 @@ function HighlightRenderer({
const color = theme[shape.props.color].highlight[colorSpace]
return (
<SVGContainer id={shape.id} style={{ opacity }}>
<path
d={solidStrokePath}
strokeLinecap="round"
fill="none"
pointerEvents="all"
stroke={color}
strokeWidth={sw}
/>
</SVGContainer>
<path
d={solidStrokePath}
strokeLinecap="round"
fill="none"
pointerEvents="all"
stroke={color}
strokeWidth={sw}
opacity={opacity}
/>
)
}
function highlighterToSvg(
strokeWidth: number,
shape: TLHighlightShape,
opacity: number,
theme: TLDefaultColorTheme
) {
const { solidStrokePath, sw } = getHighlightSvgPath(shape, strokeWidth, false)
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', solidStrokePath)
path.setAttribute('fill', 'none')
path.setAttribute('stroke', theme[shape.props.color].highlight.srgb)
path.setAttribute('stroke-width', `${sw}`)
path.setAttribute('opacity', `${opacity}`)
return path
}
function getStrokeWidth(shape: TLHighlightShape) {
return FONT_SIZES[shape.props.size] * 1.12
}

View file

@ -171,10 +171,9 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
}
override async toSvg(shape: TLImageShape) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : null
if (!asset) return g
if (!asset) return null
let src = asset?.props.src || ''
if (src.startsWith('http') || src.startsWith('/') || src.startsWith('./')) {
@ -182,8 +181,6 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
src = (await getDataURIFromURL(src)) || ''
}
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image')
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', src)
const containerStyle = getCroppedContainerStyle(shape)
const crop = shape.props.crop
if (containerStyle.transform && crop) {
@ -198,31 +195,22 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
new Vec(0, croppedHeight),
]
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon')
polygon.setAttribute('points', points.map((p) => `${p.x},${p.y}`).join(' '))
const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath')
clipPath.setAttribute('id', 'cropClipPath')
clipPath.appendChild(polygon)
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
defs.appendChild(clipPath)
g.appendChild(defs)
const innerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
innerElement.setAttribute('clip-path', 'url(#cropClipPath)')
image.setAttribute('width', width.toString())
image.setAttribute('height', height.toString())
image.style.transform = transform
innerElement.appendChild(image)
g.appendChild(innerElement)
const cropClipId = `cropClipPath_${shape.id.replace(':', '_')}`
return (
<>
<defs>
<clipPath id={cropClipId}>
<polygon points={points.map((p) => `${p.x},${p.y}`).join(' ')} />
</clipPath>
</defs>
<g clipPath="url(#{cropClipId})">
<image href={src} width={width} height={height} style={{ transform }} />
</g>
</>
)
} else {
image.setAttribute('width', shape.props.w.toString())
image.setAttribute('height', shape.props.h.toString())
g.appendChild(image)
return <image href={src} width={shape.props.w} height={shape.props.h} />
}
return g
}
override onDoubleClick = (shape: TLImageShape) => {

View file

@ -1,4 +1,3 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
CubicSpline2d,
Group2d,
@ -6,14 +5,12 @@ import {
Polyline2d,
SVGContainer,
ShapeUtil,
SvgExportContext,
TLHandle,
TLLineShape,
TLOnHandleDragHandler,
TLOnResizeHandler,
Vec,
WeakMapCache,
getDefaultColorTheme,
getIndexBetween,
getIndices,
lineShapeMigrations,
@ -29,7 +26,6 @@ import { getDrawLinePathData } from '../shared/polygon-helpers'
import { getLineDrawPath, getLineIndicatorPath } from './components/getLinePath'
import {
getSvgPathForBezierCurve,
getSvgPathForCubicSpline,
getSvgPathForEdge,
getSvgPathForLineGeometry,
} from './components/svg'
@ -130,139 +126,11 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
}
component(shape: TLLineShape) {
const theme = useDefaultColorTheme()
const spline = getGeometryForLineShape(shape)
const strokeWidth = STROKE_SIZES[shape.props.size]
const { dash, color } = shape.props
// Line style lines
if (shape.props.spline === 'line') {
if (dash === 'solid') {
const outline = spline.points
const pathData = 'M' + outline[0] + 'L' + outline.slice(1)
return (
<SVGContainer id={shape.id}>
<ShapeFill d={pathData} fill={'none'} color={color} theme={theme} />
<path d={pathData} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</SVGContainer>
)
}
if (dash === 'dashed' || dash === 'dotted') {
const outline = spline.points
const pathData = 'M' + outline[0] + 'L' + outline.slice(1)
return (
<SVGContainer id={shape.id}>
<ShapeFill d={pathData} fill={'none'} color={color} theme={theme} />
<g stroke={theme[color].solid} strokeWidth={strokeWidth}>
{spline.segments.map((segment, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
segment.length,
strokeWidth,
{
style: dash,
start: i > 0 ? 'outset' : 'none',
end: i < spline.segments.length - 1 ? 'outset' : 'none',
}
)
return (
<path
key={i}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
d={getSvgPathForEdge(segment as any, true)}
fill="none"
/>
)
})}
</g>
</SVGContainer>
)
}
if (dash === 'draw') {
const outline = spline.points
const [innerPathData, outerPathData] = getDrawLinePathData(shape.id, outline, strokeWidth)
return (
<SVGContainer id={shape.id}>
<ShapeFill d={innerPathData} fill={'none'} color={color} theme={theme} />
<path
d={outerPathData}
stroke={theme[color].solid}
strokeWidth={strokeWidth}
fill="none"
/>
</SVGContainer>
)
}
}
// Cubic style spline
if (shape.props.spline === 'cubic') {
const splinePath = getSvgPathForLineGeometry(spline)
if (dash === 'solid') {
return (
<SVGContainer id={shape.id}>
<ShapeFill d={splinePath} fill={'none'} color={color} theme={theme} />
<path
strokeWidth={strokeWidth}
stroke={theme[color].solid}
fill="none"
d={splinePath}
/>
</SVGContainer>
)
}
if (dash === 'dashed' || dash === 'dotted') {
return (
<SVGContainer id={shape.id}>
<ShapeFill d={splinePath} fill={'none'} color={color} theme={theme} />
<g stroke={theme[color].solid} strokeWidth={strokeWidth}>
{spline.segments.map((segment, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
segment.length,
strokeWidth,
{
style: dash,
start: i > 0 ? 'outset' : 'none',
end: i < spline.segments.length - 1 ? 'outset' : 'none',
}
)
return (
<path
key={i}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
d={getSvgPathForBezierCurve(segment as any, true)}
fill="none"
/>
)
})}
</g>
</SVGContainer>
)
}
if (dash === 'draw') {
return (
<SVGContainer id={shape.id}>
<ShapeFill d={splinePath} fill={'none'} color={color} theme={theme} />
<path
d={getLineDrawPath(shape, spline, strokeWidth)}
strokeWidth={1}
stroke={theme[color].solid}
fill={theme[color].solid}
/>
</SVGContainer>
)
}
}
return (
<SVGContainer id={shape.id}>
<LineShapeSvg shape={shape} />
</SVGContainer>
)
}
indicator(shape: TLLineShape) {
@ -287,79 +155,8 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
return <path d={path} />
}
override toSvg(shape: TLLineShape, ctx: SvgExportContext) {
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
const color = theme[shape.props.color].solid
const spline = getGeometryForLineShape(shape)
const strokeWidth = STROKE_SIZES[shape.props.size]
switch (shape.props.dash) {
case 'draw': {
let pathData: string
if (spline instanceof CubicSpline2d) {
pathData = getLineDrawPath(shape, spline, strokeWidth)
} else {
const [_, outerPathData] = getDrawLinePathData(shape.id, spline.points, strokeWidth)
pathData = outerPathData
}
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
p.setAttribute('stroke-width', strokeWidth + 'px')
p.setAttribute('stroke', color)
p.setAttribute('fill', 'none')
p.setAttribute('d', pathData)
return p
}
case 'solid': {
let pathData: string
if (spline instanceof CubicSpline2d) {
pathData = getSvgPathForCubicSpline(spline, false)
} else {
const outline = spline.points
pathData = 'M' + outline[0] + 'L' + outline.slice(1)
}
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path')
p.setAttribute('stroke-width', strokeWidth + 'px')
p.setAttribute('stroke', color)
p.setAttribute('fill', 'none')
p.setAttribute('d', pathData)
return p
}
default: {
const { segments } = spline
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.setAttribute('stroke', color)
g.setAttribute('stroke-width', strokeWidth.toString())
const fn = spline instanceof CubicSpline2d ? getSvgPathForBezierCurve : getSvgPathForEdge
segments.forEach((segment, i) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
segment.length,
strokeWidth,
{
style: shape.props.dash,
start: i > 0 ? 'outset' : 'none',
end: i < segments.length - 1 ? 'outset' : 'none',
}
)
path.setAttribute('stroke-dasharray', strokeDasharray.toString())
path.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
path.setAttribute('d', fn(segment as any, true))
path.setAttribute('fill', 'none')
g.appendChild(path)
})
return g
}
}
override toSvg(shape: TLLineShape) {
return <LineShapeSvg shape={shape} />
}
override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry {
@ -411,3 +208,134 @@ export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Pol
}
}
}
function LineShapeSvg({ shape }: { shape: TLLineShape }) {
const theme = useDefaultColorTheme()
const spline = getGeometryForLineShape(shape)
const strokeWidth = STROKE_SIZES[shape.props.size]
const { dash, color } = shape.props
// Line style lines
if (shape.props.spline === 'line') {
if (dash === 'solid') {
const outline = spline.points
const pathData = 'M' + outline[0] + 'L' + outline.slice(1)
return (
<>
<ShapeFill d={pathData} fill={'none'} color={color} theme={theme} />
<path d={pathData} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
}
if (dash === 'dashed' || dash === 'dotted') {
const outline = spline.points
const pathData = 'M' + outline[0] + 'L' + outline.slice(1)
return (
<>
<ShapeFill d={pathData} fill={'none'} color={color} theme={theme} />
<g stroke={theme[color].solid} strokeWidth={strokeWidth}>
{spline.segments.map((segment, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
segment.length,
strokeWidth,
{
style: dash,
start: i > 0 ? 'outset' : 'none',
end: i < spline.segments.length - 1 ? 'outset' : 'none',
}
)
return (
<path
key={i}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
d={getSvgPathForEdge(segment as any, true)}
fill="none"
/>
)
})}
</g>
</>
)
}
if (dash === 'draw') {
const outline = spline.points
const [innerPathData, outerPathData] = getDrawLinePathData(shape.id, outline, strokeWidth)
return (
<>
<ShapeFill d={innerPathData} fill={'none'} color={color} theme={theme} />
<path
d={outerPathData}
stroke={theme[color].solid}
strokeWidth={strokeWidth}
fill="none"
/>
</>
)
}
}
// Cubic style spline
if (shape.props.spline === 'cubic') {
const splinePath = getSvgPathForLineGeometry(spline)
if (dash === 'solid') {
return (
<>
<ShapeFill d={splinePath} fill={'none'} color={color} theme={theme} />
<path strokeWidth={strokeWidth} stroke={theme[color].solid} fill="none" d={splinePath} />
</>
)
}
if (dash === 'dashed' || dash === 'dotted') {
return (
<>
<ShapeFill d={splinePath} fill={'none'} color={color} theme={theme} />
<g stroke={theme[color].solid} strokeWidth={strokeWidth}>
{spline.segments.map((segment, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
segment.length,
strokeWidth,
{
style: dash,
start: i > 0 ? 'outset' : 'none',
end: i < spline.segments.length - 1 ? 'outset' : 'none',
}
)
return (
<path
key={i}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
d={getSvgPathForBezierCurve(segment as any, true)}
fill="none"
/>
)
})}
</g>
</>
)
}
if (dash === 'draw') {
return (
<>
<ShapeFill d={splinePath} fill={'none'} color={color} theme={theme} />
<path
d={getLineDrawPath(shape, spline, strokeWidth)}
strokeWidth={1}
stroke={theme[color].solid}
fill={theme[color].solid}
/>
</>
)
}
}
}

View file

@ -5,7 +5,7 @@ import { setStrokePointRadii } from '../../shared/freehand/setStrokePointRadii'
import { getSvgPathFromStrokePoints } from '../../shared/freehand/svg'
import { getSvgPathForLineGeometry } from './svg'
export function getLineDrawFreehandOptions(strokeWidth: number) {
function getLineDrawFreehandOptions(strokeWidth: number) {
return {
size: strokeWidth,
thinning: 0.4,
@ -16,18 +16,7 @@ export function getLineDrawFreehandOptions(strokeWidth: number) {
}
}
export function getLineSolidFreehandOptions(strokeWidth: number) {
return {
size: strokeWidth,
thinning: 0,
streamline: 0,
smoothing: 0.5,
simulatePressure: false,
last: true,
}
}
export function getLineStrokePoints(
function getLineStrokePoints(
shape: TLLineShape,
spline: CubicSpline2d | Polyline2d,
strokeWidth: number
@ -38,7 +27,7 @@ export function getLineStrokePoints(
return getStrokePoints(points, options)
}
export function getLineDrawStrokeOutlinePoints(
function getLineDrawStrokeOutlinePoints(
shape: TLLineShape,
spline: CubicSpline2d | Polyline2d,
strokeWidth: number
@ -50,15 +39,6 @@ export function getLineDrawStrokeOutlinePoints(
)
}
export function getLineSolidStrokeOutlinePoints(
shape: TLLineShape,
spline: CubicSpline2d | Polyline2d,
strokeWidth: number
) {
const options = getLineSolidFreehandOptions(strokeWidth)
return getStrokeOutlinePoints(getLineStrokePoints(shape, spline, strokeWidth), options)
}
export function getLineDrawPath(
shape: TLLineShape,
spline: CubicSpline2d | Polyline2d,
@ -68,15 +48,6 @@ export function getLineDrawPath(
return getSvgPathFromPoints(stroke)
}
export function getLineSolidPath(
shape: TLLineShape,
spline: CubicSpline2d | Polyline2d,
strokeWidth: number
) {
const outlinePoints = getLineSolidStrokeOutlinePoints(shape, spline, strokeWidth)
return getSvgPathFromPoints(outlinePoints)
}
export function getLineIndicatorPath(
shape: TLLineShape,
spline: CubicSpline2d | Polyline2d,

View file

@ -29,7 +29,7 @@ export function getSvgPathForBezierCurve(curve: CubicBezier2d, first: boolean) {
)},${toDomPrecision(d.y)}`
}
export function getSvgPathForCubicSpline(spline: CubicSpline2d, isClosed: boolean) {
function getSvgPathForCubicSpline(spline: CubicSpline2d, isClosed: boolean) {
let d = spline.segments.reduce((d, segment, i) => {
return d + getSvgPathForBezierCurve(segment, i === 0)
}, '')
@ -41,7 +41,7 @@ export function getSvgPathForCubicSpline(spline: CubicSpline2d, isClosed: boolea
return d
}
export function getSvgPathForPolylineSpline(spline: Polyline2d, isClosed: boolean) {
function getSvgPathForPolylineSpline(spline: Polyline2d, isClosed: boolean) {
let d = spline.segments.reduce((d, segment, i) => {
return d + getSvgPathForEdge(segment, i === 0)
}, '')

View file

@ -1,5 +1,4 @@
import {
DefaultFontFamilies,
Editor,
Rectangle2d,
ShapeUtil,
@ -13,10 +12,10 @@ import {
} from '@tldraw/editor'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
import { getTextLabelSvgElement } from '../shared/getTextLabelSvgElement'
const NOTE_SIZE = 200
@ -112,42 +111,34 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
override toSvg(shape: TLNoteShape, ctx: SvgExportContext) {
ctx.addExportDef(getFontDefForExport(shape.props.font))
if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font))
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
const bounds = this.editor.getShapeGeometry(shape).bounds
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const adjustedColor = shape.props.color === 'black' ? 'yellow' : shape.props.color
const rect1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect1.setAttribute('rx', '10')
rect1.setAttribute('width', NOTE_SIZE.toString())
rect1.setAttribute('height', bounds.height.toString())
rect1.setAttribute('fill', theme[adjustedColor].solid)
rect1.setAttribute('stroke', theme[adjustedColor].solid)
rect1.setAttribute('stroke-width', '1')
g.appendChild(rect1)
const rect2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect2.setAttribute('rx', '10')
rect2.setAttribute('width', NOTE_SIZE.toString())
rect2.setAttribute('height', bounds.height.toString())
rect2.setAttribute('fill', theme.background)
rect2.setAttribute('opacity', '.28')
g.appendChild(rect2)
const textElm = getTextLabelSvgElement({
editor: this.editor,
shape,
font: DefaultFontFamilies[shape.props.font],
bounds,
})
textElm.setAttribute('fill', theme.text)
textElm.setAttribute('stroke', 'none')
g.appendChild(textElm)
return g
return (
<>
<rect
rx={10}
width={NOTE_SIZE}
height={bounds.h}
fill={theme[adjustedColor].solid}
stroke={theme[adjustedColor].solid}
strokeWidth={1}
/>
<rect rx={10} width={NOTE_SIZE} height={bounds.h} fill={theme.background} opacity={0.28} />
<SvgTextLabel
fontSize={LABEL_FONT_SIZES[shape.props.size]}
font={shape.props.font}
align={shape.props.align}
verticalAlign={shape.props.verticalAlign}
text={shape.props.text}
labelColor="black"
bounds={bounds}
stroke={false}
/>
</>
)
}
override onBeforeCreate = (next: TLNoteShape) => {

View file

@ -6,6 +6,7 @@ import {
getDefaultColorTheme,
useEditor,
useIsDarkMode,
useSvgExportContext,
useValue,
} from '@tldraw/editor'
import React from 'react'
@ -40,6 +41,7 @@ export const ShapeFill = React.memo(function ShapeFill({ theme, d, color, fill }
const PatternFill = function PatternFill({ d, color, theme }: ShapeFillProps) {
const editor = useEditor()
const svgExport = useSvgExportContext()
const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor])
const intZoom = Math.ceil(zoomLevel)
@ -50,64 +52,14 @@ const PatternFill = function PatternFill({ d, color, theme }: ShapeFillProps) {
<path fill={theme[color].pattern} d={d} />
<path
fill={
teenyTiny
? theme[color].semi
: `url(#${HASH_PATTERN_ZOOM_NAMES[`${intZoom}_${theme.id}`]})`
svgExport
? `url(#${HASH_PATTERN_ZOOM_NAMES[`1_${theme.id}`]})`
: teenyTiny
? theme[color].semi
: `url(#${HASH_PATTERN_ZOOM_NAMES[`${intZoom}_${theme.id}`]})`
}
d={d}
/>
</>
)
}
export function getShapeFillSvg({ d, color, fill, theme }: ShapeFillProps) {
if (fill === 'none') {
return
}
if (fill === 'pattern') {
const gEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const path1El = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path1El.setAttribute('d', d)
path1El.setAttribute('fill', theme[color].pattern)
const path2El = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path2El.setAttribute('d', d)
path2El.setAttribute('fill', `url(#hash_pattern)`)
gEl.appendChild(path1El)
gEl.appendChild(path2El)
return gEl
}
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
switch (fill) {
case 'semi': {
path.setAttribute('fill', theme.solid)
break
}
case 'solid': {
{
path.setAttribute('fill', theme[color].semi)
}
break
}
}
return path
}
export function getSvgWithShapeFill(foregroundPath: SVGElement, backgroundPath?: SVGElement) {
if (backgroundPath) {
// If there is a fill element, return a group containing the fill and the path
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.appendChild(backgroundPath)
g.appendChild(foregroundPath)
return g
} else {
// Otherwise, just return the path
return foregroundPath
}
}

View file

@ -0,0 +1,85 @@
import {
Box,
DefaultFontFamilies,
TLDefaultColorStyle,
TLDefaultFontStyle,
TLDefaultHorizontalAlignStyle,
TLDefaultVerticalAlignStyle,
useEditor,
} from '@tldraw/editor'
import { useDefaultColorTheme } from './ShapeFill'
import { createTextJsxFromSpans } from './createTextJsxFromSpans'
import { TEXT_PROPS } from './default-shape-constants'
import { getLegacyOffsetX } from './legacyProps'
export function SvgTextLabel({
fontSize,
font,
align,
verticalAlign,
text,
labelColor,
bounds,
padding = 16,
stroke = true,
}: {
fontSize: number
font: TLDefaultFontStyle
// fill?: TLDefaultFillStyle
align: TLDefaultHorizontalAlignStyle
verticalAlign: TLDefaultVerticalAlignStyle
wrap?: boolean
text: string
labelColor: TLDefaultColorStyle
bounds: Box
padding?: number
stroke?: boolean
}) {
const editor = useEditor()
const theme = useDefaultColorTheme()
const opts = {
fontSize,
fontFamily: DefaultFontFamilies[font],
textAlign: align,
verticalTextAlign: verticalAlign,
width: Math.ceil(bounds.width),
height: Math.ceil(bounds.height),
padding,
lineHeight: TEXT_PROPS.lineHeight,
fontStyle: 'normal',
fontWeight: 'normal',
overflow: 'wrap' as const,
offsetX: 0,
offsetY: 0,
fill: theme[labelColor].solid,
stroke: undefined as string | undefined,
strokeWidth: undefined as number | undefined,
}
const spans = editor.textMeasure.measureTextSpans(text, opts)
const offsetX = getLegacyOffsetX(align, padding, spans, bounds.width)
if (offsetX) {
opts.offsetX = offsetX
}
opts.offsetX += bounds.x
opts.offsetY += bounds.y
const mainSpans = createTextJsxFromSpans(editor, spans, opts)
let outlineSpans = null
if (stroke) {
opts.fill = theme.background
opts.stroke = theme.background
opts.strokeWidth = 2
outlineSpans = createTextJsxFromSpans(editor, spans, opts)
}
return (
<>
{outlineSpans}
{mainSpans}
</>
)
}

View file

@ -0,0 +1,101 @@
import {
Box,
BoxModel,
Editor,
TLDefaultHorizontalAlignStyle,
TLDefaultVerticalAlignStyle,
} from '@tldraw/editor'
function correctSpacesToNbsp(input: string) {
return input.replace(/\s/g, '\xa0')
}
/** Get an SVG element for a text shape. */
export function createTextJsxFromSpans(
editor: Editor,
spans: { text: string; box: BoxModel }[],
opts: {
fontSize: number
fontFamily: string
textAlign: TLDefaultHorizontalAlignStyle
verticalTextAlign: TLDefaultVerticalAlignStyle
fontWeight: string
fontStyle: string
width: number
height: number
stroke?: string
strokeWidth?: number
fill?: string
padding?: number
offsetX?: number
offsetY?: number
}
) {
const { padding = 0 } = opts
if (spans.length === 0) return null
const bounds = Box.From(spans[0].box)
for (const { box } of spans) {
bounds.union(box)
}
const offsetX = padding + (opts.offsetX ?? 0)
const offsetY =
(opts.offsetY ?? 0) +
opts.fontSize / 2 +
(opts.verticalTextAlign === 'start'
? padding
: opts.verticalTextAlign === 'end'
? opts.height - padding - bounds.height
: (Math.ceil(opts.height) - bounds.height) / 2)
// Create text span elements for each word
let currentLineTop = null
const children = []
for (const { text, box } of spans) {
// if we broke a line, add a line break span. This helps tools like
// figma import our exported svg correctly
const didBreakLine = currentLineTop !== null && box.y > currentLineTop
if (didBreakLine) {
children.push(
<tspan
key={children.length}
alignmentBaseline="mathematical"
x={offsetX}
y={box.y + offsetY}
>
{'\n'}
</tspan>
)
}
children.push(
<tspan
key={children.length}
alignmentBaseline="mathematical"
x={box.x + offsetX}
y={box.y + offsetY}
>
{correctSpacesToNbsp(text)}
</tspan>
)
currentLineTop = box.y
}
return (
<text
fontSize={opts.fontSize}
fontFamily={opts.fontFamily}
fontStyle={opts.fontFamily}
fontWeight={opts.fontWeight}
dominantBaseline="mathematical"
alignmentBaseline="mathematical"
stroke={opts.stroke}
strokeWidth={opts.strokeWidth}
fill={opts.fill}
>
{children}
</text>
)
}

View file

@ -1,101 +0,0 @@
import {
Box,
BoxModel,
Editor,
TLDefaultHorizontalAlignStyle,
TLDefaultVerticalAlignStyle,
} from '@tldraw/editor'
function correctSpacesToNbsp(input: string) {
return input.replace(/\s/g, '\xa0')
}
/** Get an SVG element for a text shape. */
export function createTextSvgElementFromSpans(
editor: Editor,
spans: { text: string; box: BoxModel }[],
opts: {
fontSize: number
fontFamily: string
textAlign: TLDefaultHorizontalAlignStyle
verticalTextAlign: TLDefaultVerticalAlignStyle
fontWeight: string
fontStyle: string
lineHeight: number
width: number
height: number
stroke?: string
strokeWidth?: number
fill?: string
padding?: number
offsetX?: number
offsetY?: number
}
) {
const { padding = 0 } = opts
// Create the text element
const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text')
textElm.setAttribute('font-size', opts.fontSize + 'px')
textElm.setAttribute('font-family', opts.fontFamily)
textElm.setAttribute('font-style', opts.fontStyle)
textElm.setAttribute('font-weight', opts.fontWeight)
textElm.setAttribute('line-height', opts.lineHeight * opts.fontSize + 'px')
textElm.setAttribute('dominant-baseline', 'mathematical')
textElm.setAttribute('alignment-baseline', 'mathematical')
if (spans.length === 0) return textElm
const bounds = Box.From(spans[0].box)
for (const { box } of spans) {
bounds.union(box)
}
const offsetX = padding + (opts.offsetX ?? 0)
// const offsetY = (Math.ceil(opts.height) - bounds.height + opts.fontSize) / 2 + (opts.offsetY ?? 0)
const offsetY =
(opts.offsetY ?? 0) +
opts.fontSize / 2 +
(opts.verticalTextAlign === 'start'
? padding
: opts.verticalTextAlign === 'end'
? opts.height - padding - bounds.height
: (Math.ceil(opts.height) - bounds.height) / 2)
// Create text span elements for each word
let currentLineTop = null
for (const { text, box } of spans) {
// if we broke a line, add a line break span. This helps tools like
// figma import our exported svg correctly
const didBreakLine = currentLineTop !== null && box.y > currentLineTop
if (didBreakLine) {
const lineBreakTspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
lineBreakTspan.setAttribute('alignment-baseline', 'mathematical')
lineBreakTspan.setAttribute('x', offsetX + 'px')
lineBreakTspan.setAttribute('y', box.y + offsetY + 'px')
lineBreakTspan.textContent = '\n'
textElm.appendChild(lineBreakTspan)
}
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
tspan.setAttribute('alignment-baseline', 'mathematical')
tspan.setAttribute('x', box.x + offsetX + 'px')
tspan.setAttribute('y', box.y + offsetY + 'px')
const cleanText = correctSpacesToNbsp(text)
tspan.textContent = cleanText
textElm.appendChild(tspan)
currentLineTop = box.y
}
if (opts.stroke && opts.strokeWidth) {
textElm.setAttribute('stroke', opts.stroke)
textElm.setAttribute('stroke-width', opts.strokeWidth + 'px')
}
if (opts.fill) {
textElm.setAttribute('fill', opts.fill)
}
return textElm
}

View file

@ -49,13 +49,7 @@ export const FONT_FAMILIES: Record<TLDefaultFontStyle, string> = {
mono: 'var(--tl-font-mono)',
}
/** @internal */
export const MIN_ARROW_LENGTH = 48
/** @internal */
export const BOUND_ARROW_OFFSET = 10
/** @internal */
export const LABEL_TO_ARROW_PADDING = 20
/** @internal */
export const ARROW_LABEL_PADDING = 4.25
/** @internal */
export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10

View file

@ -6,7 +6,6 @@ import {
HASH_PATTERN_ZOOM_NAMES,
MAX_ZOOM,
SvgExportDef,
TLDefaultColorTheme,
TLDefaultFillStyle,
TLDefaultFontStyle,
TLShapeUtilCanvasSvgDef,
@ -14,6 +13,7 @@ import {
useEditor,
} from '@tldraw/editor'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useDefaultColorTheme } from './ShapeFill'
/** @public */
export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef {
@ -31,9 +31,7 @@ export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef
const base64FontFile = await FileHelpers.blobToDataUrl(fontFile)
const newFontFaceRule = fontFaceRule.replace(url, base64FontFile)
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
style.textContent = newFontFaceRule
return style
return <style>{newFontFaceRule}</style>
},
}
}
@ -49,47 +47,42 @@ function findFont(name: TLDefaultFontStyle): FontFace | null {
}
/** @public */
export function getFillDefForExport(
fill: TLDefaultFillStyle,
theme: TLDefaultColorTheme
): SvgExportDef {
export function getFillDefForExport(fill: TLDefaultFillStyle): SvgExportDef {
return {
key: `${DefaultFontStyle.id}:${fill}`,
getElement: async () => {
if (fill !== 'pattern') return null
const t = 8 / 12
const divEl = document.createElement('div')
divEl.innerHTML = `
<svg>
<defs>
<mask id="hash_pattern_mask">
<rect x="0" y="0" width="8" height="8" fill="white" />
<g
strokeLinecap="round"
stroke="black"
>
<line x1="${t * 1}" y1="${t * 3}" x2="${t * 3}" y2="${t * 1}" />
<line x1="${t * 5}" y1="${t * 7}" x2="${t * 7}" y2="${t * 5}" />
<line x1="${t * 9}" y1="${t * 11}" x2="${t * 11}" y2="${t * 9}" />
</g>
</mask>
<pattern
id="hash_pattern"
width="8"
height="8"
patternUnits="userSpaceOnUse"
>
<rect x="0" y="0" width="8" height="8" fill="${theme.solid}" mask="url(#hash_pattern_mask)" />
</pattern>
</defs>
</svg>
`
return Array.from(divEl.querySelectorAll('defs > *'))
return <HashPatternForExport />
},
}
}
function HashPatternForExport() {
const theme = useDefaultColorTheme()
const t = 8 / 12
return (
<>
<mask id="hash_pattern_mask">
<rect x="0" y="0" width="8" height="8" fill="white" />
<g strokeLinecap="round" stroke="black">
<line x1={t * 1} y1={t * 3} x2={t * 3} y2={t * 1} />
<line x1={t * 5} y1={t * 7} x2={t * 7} y2={t * 5} />
<line x1={t * 9} y1={t * 11} x2={t * 11} y2={t * 9} />
</g>
</mask>
<pattern
id={HASH_PATTERN_ZOOM_NAMES[`1_${theme.id}`]}
width="8"
height="8"
patternUnits="userSpaceOnUse"
>
<rect x="0" y="0" width="8" height="8" fill={theme.solid} mask="url(#hash_pattern_mask)" />
</pattern>
</>
)
}
export function getFillDefForCanvas(): TLShapeUtilCanvasSvgDef {
return {
key: `${DefaultFontStyle.id}:pattern`,

View file

@ -151,7 +151,7 @@ function circlePath(cx: number, cy: number, r: number) {
)
}
export function renderPartition(strokePoints: StrokePoint[], options: StrokeOptions = {}): string {
function renderPartition(strokePoints: StrokePoint[], options: StrokeOptions = {}): string {
if (strokePoints.length === 0) return ''
if (strokePoints.length === 1) {
return circlePath(strokePoints[0].point.x, strokePoints[0].point.y, strokePoints[0].radius)

View file

@ -8,7 +8,7 @@ export type CanvasMaxSize = {
let maxSizePromise: Promise<CanvasMaxSize> | null = null
export function getBrowserCanvasMaxSize() {
function getBrowserCanvasMaxSize() {
if (!maxSizePromise) {
maxSizePromise = calculateBrowserCanvasMaxSize()
}
@ -28,8 +28,8 @@ async function calculateBrowserCanvasMaxSize(): Promise<CanvasMaxSize> {
}
// https://github.com/jhildenbiddle/canvas-size?tab=readme-ov-file#test-results
export const MAX_SAFE_CANVAS_DIMENSION = 8192
export const MAX_SAFE_CANVAS_AREA = 4096 * 4096
const MAX_SAFE_CANVAS_DIMENSION = 8192
const MAX_SAFE_CANVAS_AREA = 4096 * 4096
export async function clampToBrowserMaxCanvasSize(width: number, height: number) {
if (

View file

@ -1,42 +0,0 @@
import { Box, Editor, TLGeoShape, TLNoteShape } from '@tldraw/editor'
import { createTextSvgElementFromSpans } from './createTextSvgElementFromSpans'
import { LABEL_FONT_SIZES, TEXT_PROPS } from './default-shape-constants'
import { getLegacyOffsetX } from './legacyProps'
export function getTextLabelSvgElement({
bounds,
editor,
font,
shape,
}: {
bounds: Box
editor: Editor
font: string
shape: TLGeoShape | TLNoteShape
}) {
const padding = 16
const opts = {
fontSize: LABEL_FONT_SIZES[shape.props.size],
fontFamily: font,
textAlign: shape.props.align,
verticalTextAlign: shape.props.verticalAlign,
width: Math.ceil(bounds.width),
height: Math.ceil(bounds.height),
padding: 16,
lineHeight: TEXT_PROPS.lineHeight,
fontStyle: 'normal',
fontWeight: 'normal',
overflow: 'wrap' as const,
offsetX: 0,
}
const spans = editor.textMeasure.measureTextSpans(shape.props.text, opts)
const offsetX = getLegacyOffsetX(shape.props.align, padding, spans, bounds.width)
if (offsetX) {
opts.offsetX = offsetX
}
const textElm = createTextSvgElementFromSpans(editor, spans, opts)
return textElm
}

View file

@ -1,6 +1,6 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
DefaultFontFamilies,
Box,
Editor,
HTMLContainer,
Rectangle2d,
@ -19,7 +19,7 @@ import {
toDomPrecision,
useEditor,
} from '@tldraw/editor'
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
import { resizeScaled } from '../shared/resizeScaled'
@ -149,51 +149,24 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
override toSvg(shape: TLTextShape, ctx: SvgExportContext) {
ctx.addExportDef(getFontDefForExport(shape.props.font))
if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font))
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
const bounds = this.editor.getShapeGeometry(shape).bounds
const text = shape.props.text
const width = bounds.width / (shape.props.scale ?? 1)
const height = bounds.height / (shape.props.scale ?? 1)
const opts = {
fontSize: FONT_SIZES[shape.props.size],
fontFamily: DefaultFontFamilies[shape.props.font],
textAlign: shape.props.align,
verticalTextAlign: 'middle' as const,
width,
height,
padding: 0, // no padding?
lineHeight: TEXT_PROPS.lineHeight,
fontStyle: 'normal',
fontWeight: 'normal',
overflow: 'wrap' as const,
}
const color = theme[shape.props.color].solid
const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const textBgEl = createTextSvgElementFromSpans(
this.editor,
this.editor.textMeasure.measureTextSpans(text, opts),
{
...opts,
stroke: theme.background,
strokeWidth: 2,
fill: theme.background,
padding: 0,
}
return (
<SvgTextLabel
fontSize={FONT_SIZES[shape.props.size]}
font={shape.props.font}
align={shape.props.align}
verticalAlign="middle"
text={shape.props.text}
labelColor={shape.props.color}
bounds={new Box(0, 0, width, height)}
padding={0}
/>
)
const textElm = textBgEl.cloneNode(true) as SVGTextElement
textElm.setAttribute('fill', color)
textElm.setAttribute('stroke', 'none')
groupEl.append(textBgEl)
groupEl.append(textElm)
return groupEl
}
override onResize: TLOnResizeHandler<TLTextShape> = (shape, info) => {

View file

@ -198,14 +198,7 @@ export class VideoShapeUtil extends BaseBoxShapeUtil<TLVideoShape> {
}
override toSvg(shape: TLVideoShape) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image')
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', serializeVideo(shape.id))
image.setAttribute('width', shape.props.w.toString())
image.setAttribute('height', shape.props.h.toString())
g.appendChild(image)
return g
return <image href={serializeVideo(shape.id)} width={shape.props.w} height={shape.props.h} />
}
}

View file

@ -142,14 +142,14 @@ export function usePrint() {
window.addEventListener('beforeprint', beforePrintHandler)
window.addEventListener('afterprint', afterPrintHandler)
function addPageToPrint(title: string, footer: string | null, svg: SVGElement) {
function addPageToPrint(title: string, footer: string | null, svg: string) {
try {
el.innerHTML += `<div class="${className}__item">
<div class="${className}__item__header">
${title.replace(/</g, '&lt;').replace(/>/g, '&gt;')}
</div>
<div class="${className}__item__main">
${svg.outerHTML}
${svg}
</div>
<div class="${className}__item__footer ${className}__item__footer__${footer ? '' : 'hide'}">
${footer ?? ''}
@ -188,11 +188,11 @@ export function usePrint() {
if (editor.getSelectedShapeIds().length > 0) {
// Print the selected ids from the current page
const svg = await editor.getSvg(selectedShapeIds, svgOpts)
const svgExport = await editor.getSvgString(selectedShapeIds, svgOpts)
if (svg) {
if (svgExport) {
const page = pages.find((p) => p.id === currentPageId)
addPageToPrint(`tldraw — ${page?.name}`, null, svg)
addPageToPrint(`tldraw — ${page?.name}`, null, svgExport.svg)
triggerPrint()
}
} else {
@ -200,17 +200,23 @@ export function usePrint() {
// Print all pages
for (let i = 0; i < pages.length; i++) {
const page = pages[i]
const svg = await editor.getSvg(editor.getSortedChildIdsForParent(page.id), svgOpts)
if (svg) {
addPageToPrint(`tldraw — ${page.name}`, `${i}/${pages.length}`, svg)
const svgExport = await editor.getSvgString(
editor.getSortedChildIdsForParent(page.id),
svgOpts
)
if (svgExport) {
addPageToPrint(`tldraw — ${page.name}`, `${i}/${pages.length}`, svgExport.svg)
}
}
triggerPrint()
} else {
const page = editor.getCurrentPage()
const svg = await editor.getSvg(editor.getSortedChildIdsForParent(page.id), svgOpts)
if (svg) {
addPageToPrint(`tldraw — ${page.name}`, null, svg)
const svgExport = await editor.getSvgString(
editor.getSortedChildIdsForParent(page.id),
svgOpts
)
if (svgExport) {
addPageToPrint(`tldraw — ${page.name}`, null, svgExport.svg)
triggerPrint()
}
}

View file

@ -1,6 +1,5 @@
import {
Editor,
FileHelpers,
PngHelpers,
TLShapeId,
TLSvgOptions,
@ -11,18 +10,18 @@ import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanva
/** @public */
export async function getSvgAsImage(
svg: SVGElement,
svgString: string,
isSafari: boolean,
options: {
type: 'png' | 'jpeg' | 'webp'
quality: number
scale: number
width: number
height: number
}
) {
const { type, quality, scale } = options
const { type, quality, scale, width, height } = options
const width = +svg.getAttribute('width')!
const height = +svg.getAttribute('height')!
let [clampedWidth, clampedHeight] = await clampToBrowserMaxCanvasSize(
width * scale,
height * scale
@ -31,7 +30,6 @@ export async function getSvgAsImage(
clampedHeight = Math.floor(clampedHeight)
const effectiveScale = clampedWidth / width
const svgString = await getSvgAsString(svg)
const svgUrl = URL.createObjectURL(new Blob([svgString], { type: 'image/svg+xml' }))
const canvas = await new Promise<HTMLCanvasElement | null>((resolve) => {
@ -96,36 +94,8 @@ export async function getSvgAsImage(
}
}
/** @public */
export async function getSvgAsString(svg: SVGElement) {
const clone = svg.cloneNode(true) as SVGGraphicsElement
svg.setAttribute('width', +svg.getAttribute('width')! + '')
svg.setAttribute('height', +svg.getAttribute('height')! + '')
const imgs = Array.from(clone.querySelectorAll('image')) as SVGImageElement[]
for (const img of imgs) {
const src = img.getAttribute('xlink:href')
if (src) {
if (!src.startsWith('data:')) {
const blob = await (await fetch(src)).blob()
const base64 = await FileHelpers.blobToDataUrl(blob)
img.setAttribute('xlink:href', base64)
}
}
}
const out = new XMLSerializer()
.serializeToString(clone)
.replaceAll('&#10; ', '')
.replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
return out
}
async function getSvg(editor: Editor, ids: TLShapeId[], opts: Partial<TLSvgOptions>) {
const svg = await editor.getSvg(ids?.length ? ids : [...editor.getCurrentPageShapeIds()], {
async function getSvgString(editor: Editor, ids: TLShapeId[], opts: Partial<TLSvgOptions>) {
const svg = await editor.getSvgString(ids?.length ? ids : [...editor.getCurrentPageShapeIds()], {
scale: 1,
background: editor.getInstanceState().exportBackground,
...opts,
@ -144,7 +114,7 @@ export async function exportToString(
) {
switch (format) {
case 'svg': {
return getSvgAsString(await getSvg(editor, ids, opts))
return (await getSvgString(editor, ids, opts))?.svg
}
case 'json': {
const data = editor.getContentFromCurrentPage(ids)
@ -184,15 +154,15 @@ export async function exportToBlob({
case 'jpeg':
case 'png':
case 'webp': {
const image = await getSvgAsImage(
await getSvg(editor, ids, opts),
editor.environment.isSafari,
{
type: format,
quality: 1,
scale: 2,
}
)
const svgResult = await getSvgString(editor, ids, opts)
if (!svgResult) throw new Error('Could not construct image.')
const image = await getSvgAsImage(svgResult.svg, editor.environment.isSafari, {
type: format,
quality: 1,
scale: 2,
width: svgResult.width,
height: svgResult.height,
})
if (!image) {
throw new Error('Could not construct image.')
}

View file

@ -1,174 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Matches a snapshot: Basic SVG 1`] = `
<wrapper>
<svg
direction="ltr"
height="564"
stroke-linecap="round"
stroke-linejoin="round"
style="background-color: transparent;"
viewBox="-32 -32 564 564"
width="564"
>
<defs>
<!--def: tldraw:font:pattern-->
<mask
id="hash_pattern_mask"
>
<rect
fill="white"
height="8"
width="8"
x="0"
y="0"
/>
<g
stroke="black"
strokelinecap="round"
>
<line
x1="0.6666666666666666"
x2="2"
y1="2"
y2="0.6666666666666666"
/>
<line
x1="3.333333333333333"
x2="4.666666666666666"
y1="4.666666666666666"
y2="3.333333333333333"
/>
<line
x1="6"
x2="7.333333333333333"
y1="7.333333333333333"
y2="6"
/>
</g>
</mask>
<pattern
height="8"
id="hash_pattern"
patternUnits="userSpaceOnUse"
width="8"
>
<rect
fill="#fcfffe"
height="8"
mask="url(#hash_pattern_mask)"
width="8"
x="0"
y="0"
/>
</pattern>
</defs>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 0, 0)"
>
<g>
<path
d="M0, 0L100, 0,100, 428,0, 428Z"
fill="none"
stroke="#1d1d1d"
stroke-width="3.5"
/>
<g>
<text
alignment-baseline="mathematical"
dominant-baseline="mathematical"
fill="rgb(249, 250, 251)"
font-family="'tldraw_draw', sans-serif"
font-size="22px"
font-style="normal"
font-weight="normal"
line-height="29.700000000000003px"
stroke="rgb(249, 250, 251)"
stroke-width="2"
>
<tspan
alignment-baseline="mathematical"
x="16px"
y="-17px"
>
Hello world
</tspan>
</text>
<text
alignment-baseline="mathematical"
dominant-baseline="mathematical"
fill="#1d1d1d"
font-family="'tldraw_draw', sans-serif"
font-size="22px"
font-style="normal"
font-weight="normal"
line-height="29.700000000000003px"
stroke="none"
>
<tspan
alignment-baseline="mathematical"
x="16px"
y="-17px"
>
Hello world
</tspan>
</text>
</g>
</g>
</g>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 100, 100)"
>
<path
d="M0, 0L50, 0,50, 50,0, 50Z"
fill="none"
stroke="#1d1d1d"
stroke-width="3.5"
/>
</g>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 400, 400)"
>
<g>
<g>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="#494949"
/>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="url(#hash_pattern)"
/>
</g>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="none"
stroke="#1d1d1d"
stroke-width="3.5"
/>
</g>
</g>
</svg>
</wrapper>
`;

View file

@ -0,0 +1,145 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Matches a snapshot: Basic SVG 1`] = `
<wrapper>
<svg
direction="ltr"
height="564"
stroke-linecap="round"
stroke-linejoin="round"
style="background-color:transparent"
viewBox="-32 -32 564 564"
width="564"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask
id="hash_pattern_mask"
>
<rect
fill="white"
height="8"
width="8"
x="0"
y="0"
/>
<g
stroke="black"
stroke-linecap="round"
>
<line
x1="0.6666666666666666"
x2="2"
y1="2"
y2="0.6666666666666666"
/>
<line
x1="3.333333333333333"
x2="4.666666666666666"
y1="4.666666666666666"
y2="3.333333333333333"
/>
<line
x1="6"
x2="7.333333333333333"
y1="7.333333333333333"
y2="6"
/>
</g>
</mask>
<pattern
height="8"
id="hash_pattern_zoom_1_light"
patternUnits="userSpaceOnUse"
width="8"
>
<rect
fill="#fcfffe"
height="8"
mask="url(#hash_pattern_mask)"
width="8"
x="0"
y="0"
/>
</pattern>
</defs>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 0, 0)"
>
<path
d="M0, 0L100, 0,100, 428,0, 428Z"
fill="none"
stroke="#1d1d1d"
stroke-width="3.5"
/>
<text
alignment-baseline="mathematical"
dominant-baseline="mathematical"
fill="rgb(249, 250, 251)"
font-family="'tldraw_draw', sans-serif"
font-size="22"
font-style="'tldraw_draw', sans-serif"
font-weight="normal"
stroke="rgb(249, 250, 251)"
stroke-width="2"
>
<tspan
alignment-baseline="mathematical"
x="16"
y="-17"
>
Hello world
</tspan>
</text>
<text
alignment-baseline="mathematical"
dominant-baseline="mathematical"
fill="#1d1d1d"
font-family="'tldraw_draw', sans-serif"
font-size="22"
font-style="'tldraw_draw', sans-serif"
font-weight="normal"
>
<tspan
alignment-baseline="mathematical"
x="16"
y="-17"
>
Hello world
</tspan>
</text>
</g>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 100, 100)"
>
<path
d="M0, 0L50, 0,50, 50,0, 50Z"
fill="none"
stroke="#1d1d1d"
stroke-width="3.5"
/>
</g>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 400, 400)"
>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="#494949"
/>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="url(#hash_pattern_zoom_1_light)"
/>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="none"
stroke="#1d1d1d"
stroke-width="3.5"
/>
</g>
</svg>
</wrapper>
`;

View file

@ -9,6 +9,11 @@ const ids = {
boxC: createShapeId('boxC'),
}
const parser = new DOMParser()
function parseSvg({ svg }: { svg: string } = { svg: '' }) {
return parser.parseFromString(svg, 'image/svg+xml').firstElementChild as SVGSVGElement
}
beforeEach(() => {
editor = new TestEditor()
editor.setStyleForNextShapes(DefaultDashStyle, 'solid')
@ -51,37 +56,33 @@ beforeEach(() => {
})
it('gets an SVG', async () => {
const svg = await editor.getSvg(editor.getSelectedShapeIds())
const svg = await editor.getSvgString(editor.getSelectedShapeIds())
expect(svg).toBeTruthy()
expect(svg!.width).toBe(564)
expect(svg!.height).toBe(564)
expect(svg!.svg).toMatch(/^<svg/)
})
it('Does not get an SVG when no ids are provided', async () => {
const svg = await editor.getSvg([])
const svg = await editor.getSvgString([])
expect(svg).toBeFalsy()
})
it('Gets the bounding box at the correct size', async () => {
const svg = await editor.getSvg(editor.getSelectedShapeIds())
const svg = await editor.getSvgString(editor.getSelectedShapeIds())
const parsed = parseSvg(svg!)
const bbox = editor.getSelectionRotatedPageBounds()!
const expanded = bbox.expandBy(SVG_PADDING) // adds 32px padding
expect(svg!.getAttribute('width')).toMatch(expanded.width + '')
expect(svg!.getAttribute('height')).toMatch(expanded.height + '')
})
it('Gets the bounding box at the correct size', async () => {
const svg = (await editor.getSvg(editor.getSelectedShapeIds()))!
const bbox = editor.getSelectionRotatedPageBounds()!
const expanded = bbox.expandBy(SVG_PADDING) // adds 32px padding
expect(svg!.getAttribute('width')).toMatch(expanded.width + '')
expect(svg!.getAttribute('height')).toMatch(expanded.height + '')
expect(parsed.getAttribute('width')).toMatch(expanded.width + '')
expect(parsed.getAttribute('height')).toMatch(expanded.height + '')
expect(svg!.width).toBe(expanded.width)
expect(svg!.height).toBe(expanded.height)
})
it('Matches a snapshot', async () => {
const svg = (await editor.getSvg(editor.getSelectedShapeIds()))!
const svg = parseSvg(await editor.getSvgString(editor.getSelectedShapeIds()))
const elm = document.createElement('wrapper')
elm.appendChild(svg)
@ -90,21 +91,23 @@ it('Matches a snapshot', async () => {
})
it('Accepts a scale option', async () => {
const svg1 = (await editor.getSvg(editor.getSelectedShapeIds(), { scale: 1 }))!
const svg1 = (await editor.getSvgString(editor.getSelectedShapeIds(), { scale: 1 }))!
expect(svg1.getAttribute('width')).toBe('564')
expect(svg1.width).toBe(564)
const svg2 = (await editor.getSvg(editor.getSelectedShapeIds(), { scale: 2 }))!
const svg2 = (await editor.getSvgString(editor.getSelectedShapeIds(), { scale: 2 }))!
expect(svg2.getAttribute('width')).toBe('1128')
expect(svg2.width).toBe(1128)
})
it('Accepts a background option', async () => {
const svg1 = (await editor.getSvg(editor.getSelectedShapeIds(), { background: true }))!
const svg1 = parseSvg(
await editor.getSvgString(editor.getSelectedShapeIds(), { background: true })
)
expect(svg1.style.backgroundColor).not.toBe('transparent')
const svg2 = (await editor.getSvg(editor.getSelectedShapeIds(), { background: false }))!
const svg2 = parseSvg(
await editor.getSvgString(editor.getSelectedShapeIds(), { background: false })
)
expect(svg2.style.backgroundColor).toBe('transparent')
})

View file

@ -11,3 +11,6 @@ document.fonts = {
delete: () => {},
forEach: () => {},
}
global.TextEncoder = require('util').TextEncoder
global.TextDecoder = require('util').TextDecoder

View file

@ -1,10 +0,0 @@
import * as T from './lib/validation'
export {
ArrayOfValidator,
DictValidator,
ObjectValidator,
UnionValidator,
Validator,
} from './lib/validation'
export { T }
//# sourceMappingURL=index.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,kBAAkB,CAAA;AAErC,OAAO,EACN,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,cAAc,EACd,SAAS,GACT,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,CAAC,EAAE,CAAA"}

View file

@ -1,305 +0,0 @@
import { JsonValue } from '@tldraw/utils'
/** @public */
export type ValidatorFn<T> = (value: unknown) => T
/** @public */
export type Validatable<T> = {
validate: (value: unknown) => T
}
/** @public */
export declare class ValidationError extends Error {
readonly rawMessage: string
readonly path: ReadonlyArray<number | string>
name: string
constructor(rawMessage: string, path?: ReadonlyArray<number | string>)
}
/** @public */
export type TypeOf<V extends Validatable<unknown>> = V extends Validatable<infer T> ? T : never
/** @public */
export declare class Validator<T> implements Validatable<T> {
readonly validationFn: ValidatorFn<T>
constructor(validationFn: ValidatorFn<T>)
/**
* Asserts that the passed value is of the correct type and returns it. The returned value is
* guaranteed to be referentially equal to the passed value.
*/
validate(value: unknown): T
/** Checks that the passed value is of the correct type. */
isValid(value: unknown): value is T
/**
* Returns a new validator that also accepts null or undefined. The resulting value will always be
* null.
*/
nullable(): Validator<T | null>
/**
* Returns a new validator that also accepts null or undefined. The resulting value will always be
* null.
*/
optional(): Validator<T | undefined>
/**
* Refine this validation to a new type. The passed-in validation function should throw an error
* if the value can't be converted to the new type, or return the new type otherwise.
*/
refine<U>(otherValidationFn: (value: T) => U): Validator<U>
/**
* Refine this validation with an additional check that doesn't change the resulting value.
*
* @example
*
* ```ts
* const numberLessThan10Validator = T.number.check((value) => {
* if (value >= 10) {
* throw new ValidationError(`Expected number less than 10, got ${value}`)
* }
* })
* ```
*/
check(name: string, checkFn: (value: T) => void): Validator<T>
check(checkFn: (value: T) => void): Validator<T>
}
/** @public */
export declare class ArrayOfValidator<T> extends Validator<T[]> {
readonly itemValidator: Validatable<T>
constructor(itemValidator: Validatable<T>)
nonEmpty(): Validator<T[]>
lengthGreaterThan1(): Validator<T[]>
}
/** @public */
export declare class ObjectValidator<Shape extends object> extends Validator<Shape> {
readonly config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>
}
private readonly shouldAllowUnknownProperties
constructor(
config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>
},
shouldAllowUnknownProperties?: boolean
)
allowUnknownProperties(): ObjectValidator<Shape>
/**
* Extend an object validator by adding additional properties.
*
* @example
*
* ```ts
* const animalValidator = T.object({
* name: T.string,
* })
* const catValidator = animalValidator.extend({
* meowVolume: T.number,
* })
* ```
*/
extend<Extension extends Record<string, unknown>>(extension: {
readonly [K in keyof Extension]: Validatable<Extension[K]>
}): ObjectValidator<Shape & Extension>
}
type UnionValidatorConfig<Key extends string, Config> = {
readonly [Variant in keyof Config]: Validatable<any> & {
validate: (input: any) => {
readonly [K in Key]: Variant
}
}
}
/** @public */
export declare class UnionValidator<
Key extends string,
Config extends UnionValidatorConfig<Key, Config>,
UnknownValue = never,
> extends Validator<TypeOf<Config[keyof Config]> | UnknownValue> {
private readonly key
private readonly config
private readonly unknownValueValidation
constructor(
key: Key,
config: Config,
unknownValueValidation: (value: object, variant: string) => UnknownValue
)
validateUnknownVariants<Unknown>(
unknownValueValidation: (value: object, variant: string) => Unknown
): UnionValidator<Key, Config, Unknown>
}
/** @public */
export declare class DictValidator<Key extends string, Value> extends Validator<
Record<Key, Value>
> {
readonly keyValidator: Validatable<Key>
readonly valueValidator: Validatable<Value>
constructor(keyValidator: Validatable<Key>, valueValidator: Validatable<Value>)
}
/**
* Validation that accepts any value. Useful as a starting point for building your own custom
* validations.
*
* @public
*/
export declare const unknown: Validator<unknown>
/**
* Validation that accepts any value. Generally this should be avoided, but you can use it as an
* escape hatch if you want to work without validations for e.g. a prototype.
*
* @public
*/
export declare const any: Validator<any>
/**
* Validates that a value is a string.
*
* @public
*/
export declare const string: Validator<string>
/**
* Validates that a value is a finite non-NaN number.
*
* @public
*/
export declare const number: Validator<number>
/**
* Fails if value \< 0
*
* @public
*/
export declare const positiveNumber: Validator<number>
/**
* Fails if value \<= 0
*
* @public
*/
export declare const nonZeroNumber: Validator<number>
/**
* Fails if number is not an integer
*
* @public
*/
export declare const integer: Validator<number>
/**
* Fails if value \< 0 and is not an integer
*
* @public
*/
export declare const positiveInteger: Validator<number>
/**
* Fails if value \<= 0 and is not an integer
*
* @public
*/
export declare const nonZeroInteger: Validator<number>
/**
* Validates that a value is boolean.
*
* @public
*/
export declare const boolean: Validator<boolean>
/**
* Validates that a value is a bigint.
*
* @public
*/
export declare const bigint: Validator<bigint>
/**
* Validates that a value matches another that was passed in.
*
* @example
*
* ```ts
* const trueValidator = T.literal(true)
* ```
*
* @public
*/
export declare function literal<T extends string | number | boolean>(expectedValue: T): Validator<T>
/**
* Validates that a value is an array. To check the contents of the array, use T.arrayOf.
*
* @public
*/
export declare const array: Validator<unknown[]>
/**
* Validates that a value is an array whose contents matches the passed-in validator.
*
* @public
*/
export declare function arrayOf<T>(itemValidator: Validatable<T>): ArrayOfValidator<T>
/** @public */
export declare const unknownObject: Validator<Record<string, unknown>>
/**
* Validate an object has a particular shape.
*
* @public
*/
export declare function object<Shape extends object>(config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>
}): ObjectValidator<Shape>
/**
* Validate that a value is valid JSON.
*
* @public
*/
export declare const jsonValue: Validator<JsonValue>
/**
* Validate an object has a particular shape.
*
* @public
*/
export declare function jsonDict(): DictValidator<string, JsonValue>
/**
* Validation that an option is a dict with particular keys and values.
*
* @public
*/
export declare function dict<Key extends string, Value>(
keyValidator: Validatable<Key>,
valueValidator: Validatable<Value>
): DictValidator<Key, Value>
/**
* Validate a union of several object types. Each object must have a property matching `key` which
* should be a unique string.
*
* @example
*
* ```ts
* const catValidator = T.object({ kind: T.value('cat'), meow: T.boolean })
* const dogValidator = T.object({ kind: T.value('dog'), bark: T.boolean })
* const animalValidator = T.union('kind', { cat: catValidator, dog: dogValidator })
* ```
*
* @public
*/
export declare function union<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(
key: Key,
config: Config
): UnionValidator<Key, Config>
/**
* A named object with an ID. Errors will be reported as being part of the object with the given
* name.
*
* @public
*/
export declare function model<
T extends {
readonly id: string
},
>(name: string, validator: Validatable<T>): Validator<T>
/** @public */
export declare function setEnum<T>(values: ReadonlySet<T>): Validator<T>
/** @public */
export declare function optional<T>(validator: Validatable<T>): Validator<T | undefined>
/** @public */
export declare function nullable<T>(validator: Validatable<T>): Validator<T | null>
/** @public */
export declare function literalEnum<const Values extends readonly unknown[]>(
...values: Values
): Validator<Values[number]>
/**
* Validates that a value is a url safe to use as a link.
*
* @public
*/
export declare const linkUrl: Validator<string>
/**
* Validates that a valid is a url safe to load as an asset.
*
* @public
*/
export declare const srcUrl: Validator<string>
export {}
//# sourceMappingURL=validation.d.ts.map

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
export {}
//# sourceMappingURL=validation.test.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"validation.test.d.ts","sourceRoot":"","sources":["validation.test.ts"],"names":[],"mappings":""}