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>
|
@ -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
|
||||
|
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -13,3 +13,6 @@ document.fonts = {
|
|||
forEach: () => {},
|
||||
[Symbol.iterator]: () => [][Symbol.iterator](),
|
||||
}
|
||||
|
||||
global.TextEncoder = require('util').TextEncoder
|
||||
global.TextDecoder = require('util').TextDecoder
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 --------------------- */
|
||||
|
|
209
packages/editor/src/lib/editor/getSvgJsx.tsx
Normal 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 }
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -67,3 +67,6 @@ window.DOMRect = class DOMRect {
|
|||
this.height = height
|
||||
}
|
||||
}
|
||||
|
||||
global.TextEncoder = require('util').TextEncoder
|
||||
global.TextDecoder = require('util').TextDecoder
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
// }
|
||||
|
|
146
packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
60
packages/tldraw/src/lib/shapes/geo/getLines.tsx
Normal 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)),
|
||||
],
|
||||
]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}, '')
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
85
packages/tldraw/src/lib/shapes/shared/SvgTextLabel.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
101
packages/tldraw/src/lib/shapes/shared/createTextJsxFromSpans.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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, '<').replace(/>/g, '>')}
|
||||
</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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(' ', '')
|
||||
.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.')
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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')
|
||||
})
|
|
@ -11,3 +11,6 @@ document.fonts = {
|
|||
delete: () => {},
|
||||
forEach: () => {},
|
||||
}
|
||||
|
||||
global.TextEncoder = require('util').TextEncoder
|
||||
global.TextDecoder = require('util').TextDecoder
|
||||
|
|
10
packages/validate/src/index.d.ts
vendored
|
@ -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
|
|
@ -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"}
|
305
packages/validate/src/lib/validation.d.ts
vendored
|
@ -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
|
|
@ -1,2 +0,0 @@
|
|||
export {}
|
||||
//# sourceMappingURL=validation.test.d.ts.map
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"validation.test.d.ts","sourceRoot":"","sources":["validation.test.ts"],"names":[],"mappings":""}
|