Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
292 lines
8.4 KiB
TypeScript
292 lines
8.4 KiB
TypeScript
import { T } from '@tldraw/validate'
|
|
import { DIM_2D, DIM_3D, b64Vecs } from '../misc/b64Vecs'
|
|
import { VecModel } from '../misc/geometry-types'
|
|
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
|
|
import { RecordProps } from '../recordsWithProps'
|
|
import { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'
|
|
import { DefaultDashStyle, TLDefaultDashStyle } from '../styles/TLDashStyle'
|
|
import { DefaultFillStyle, TLDefaultFillStyle } from '../styles/TLFillStyle'
|
|
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
|
|
import { TLBaseShape } from './TLBaseShape'
|
|
|
|
/**
|
|
* A segment of a draw shape representing either freehand drawing or straight line segments.
|
|
*
|
|
* @public
|
|
*/
|
|
export interface TLDrawShapeSegment {
|
|
/** Type of drawing segment - 'free' for freehand curves, 'straight' for line segments */
|
|
type: 'free' | 'straight'
|
|
/**
|
|
* Delta-encoded base64 path data.
|
|
* First point stored as Float32 (12 bytes) for precision, subsequent points as Float16 deltas (6 bytes each).
|
|
*/
|
|
path: string
|
|
/**
|
|
* Encoding dimension of `path`. `2` means (x, y) only — the constant 0.5 pressure
|
|
* was omitted, for input from devices that don't report pressure. `3` or absent
|
|
* (the legacy default) means (x, y, z). Added in the `OmitNonPressureZ` migration.
|
|
*/
|
|
dim?: 2 | 3
|
|
}
|
|
|
|
/**
|
|
* Validator for draw shape segments ensuring proper structure and data types.
|
|
*
|
|
* @public
|
|
*/
|
|
export const DrawShapeSegment: T.ObjectValidator<TLDrawShapeSegment> = T.object({
|
|
type: T.literalEnum('free', 'straight'),
|
|
path: T.string,
|
|
dim: T.literalEnum(DIM_2D, DIM_3D).optional(),
|
|
})
|
|
|
|
/**
|
|
* Properties for the draw shape, which represents freehand drawing and sketching.
|
|
*
|
|
* @public
|
|
*/
|
|
export interface TLDrawShapeProps {
|
|
/** Color style for the drawing stroke */
|
|
color: TLDefaultColorStyle
|
|
/** Fill style for closed drawing shapes */
|
|
fill: TLDefaultFillStyle
|
|
/** Dash pattern style for the stroke */
|
|
dash: TLDefaultDashStyle
|
|
/** Size/thickness of the drawing stroke */
|
|
size: TLDefaultSizeStyle
|
|
/** Array of segments that make up the complete drawing path */
|
|
segments: TLDrawShapeSegment[]
|
|
/** Whether the drawing is complete (user finished drawing) */
|
|
isComplete: boolean
|
|
/** Whether the drawing path forms a closed shape */
|
|
isClosed: boolean
|
|
/** Whether this drawing was created with a pen/stylus device */
|
|
isPen: boolean
|
|
/** Scale factor applied to the drawing */
|
|
scale: number
|
|
/** Horizontal scale factor for lazy resize */
|
|
scaleX: number
|
|
/** Vertical scale factor for lazy resize */
|
|
scaleY: number
|
|
}
|
|
|
|
/**
|
|
* A draw shape represents freehand drawing, sketching, and pen input on the canvas.
|
|
* Draw shapes are composed of segments that can be either smooth curves or straight lines.
|
|
*
|
|
* @public
|
|
* @example
|
|
* ```ts
|
|
* const drawShape: TLDrawShape = {
|
|
* id: createShapeId(),
|
|
* typeName: 'shape',
|
|
* type: 'draw',
|
|
* x: 50,
|
|
* y: 50,
|
|
* rotation: 0,
|
|
* index: 'a1',
|
|
* parentId: 'page:page1',
|
|
* isLocked: false,
|
|
* opacity: 1,
|
|
* props: {
|
|
* color: 'black',
|
|
* fill: 'none',
|
|
* dash: 'solid',
|
|
* size: 'm',
|
|
* segments: [{
|
|
* type: 'free',
|
|
* points: [{ x: 0, y: 0, z: 0.5 }, { x: 20, y: 15, z: 0.6 }]
|
|
* }],
|
|
* isComplete: true,
|
|
* isClosed: false,
|
|
* isPen: false,
|
|
* scale: 1
|
|
* },
|
|
* meta: {}
|
|
* }
|
|
* ```
|
|
*/
|
|
export type TLDrawShape = TLBaseShape<'draw', TLDrawShapeProps>
|
|
|
|
/**
|
|
* Validation schema for draw shape properties.
|
|
*
|
|
* @public
|
|
* @example
|
|
* ```ts
|
|
* // Validate draw shape properties
|
|
* const props = {
|
|
* color: 'red',
|
|
* fill: 'solid',
|
|
* segments: [{ type: 'free', points: [] }],
|
|
* isComplete: true
|
|
* }
|
|
* const isValid = drawShapeProps.color.isValid(props.color)
|
|
* ```
|
|
*/
|
|
/** @public */
|
|
export const drawShapeProps: RecordProps<TLDrawShape> = {
|
|
color: DefaultColorStyle,
|
|
fill: DefaultFillStyle,
|
|
dash: DefaultDashStyle,
|
|
size: DefaultSizeStyle,
|
|
segments: T.arrayOf(DrawShapeSegment),
|
|
isComplete: T.boolean,
|
|
isClosed: T.boolean,
|
|
isPen: T.boolean,
|
|
scale: T.nonZeroNumber,
|
|
scaleX: T.nonZeroFiniteNumber,
|
|
scaleY: T.nonZeroFiniteNumber,
|
|
}
|
|
|
|
const Versions = createShapePropsMigrationIds('draw', {
|
|
AddInPen: 1,
|
|
AddScale: 2,
|
|
Base64: 3,
|
|
LegacyPointsConversion: 4,
|
|
OmitNonPressureZ: 5,
|
|
})
|
|
|
|
/**
|
|
* Version identifiers for draw shape migrations.
|
|
*
|
|
* @public
|
|
*/
|
|
export { Versions as drawShapeVersions }
|
|
|
|
/**
|
|
* Migration sequence for draw shape properties across different schema versions.
|
|
* Handles adding pen detection and scale properties to existing draw shapes.
|
|
*
|
|
* @public
|
|
*/
|
|
export const drawShapeMigrations = createShapePropsMigrationSequence({
|
|
sequence: [
|
|
{
|
|
id: Versions.AddInPen,
|
|
up: (props) => {
|
|
// Rather than checking to see whether the shape is a pen at runtime,
|
|
// from now on we're going to use the type of device reported to us
|
|
// as well as the pressure data received; but for existing shapes we
|
|
// need to check the pressure data to see if it's a pen or not.
|
|
|
|
const { points } = props.segments[0]
|
|
|
|
if (points.length === 0) {
|
|
props.isPen = false
|
|
return
|
|
}
|
|
|
|
let isPen = !(points[0].z === 0 || points[0].z === 0.5)
|
|
|
|
if (points[1]) {
|
|
// Double check if we have a second point (we probably should)
|
|
isPen = isPen && !(points[1].z === 0 || points[1].z === 0.5)
|
|
}
|
|
props.isPen = isPen
|
|
},
|
|
down: 'retired',
|
|
},
|
|
{
|
|
id: Versions.AddScale,
|
|
up: (props) => {
|
|
props.scale = 1
|
|
},
|
|
down: (props) => {
|
|
delete props.scale
|
|
},
|
|
},
|
|
{
|
|
id: Versions.Base64,
|
|
up: (props) => {
|
|
// Convert VecModel[] arrays directly to delta-encoded base64 in 'path'
|
|
props.segments = props.segments.map((segment: any) => {
|
|
if (segment.path !== undefined) return segment
|
|
const { points, ...rest } = segment
|
|
const vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)
|
|
return {
|
|
...rest,
|
|
path: b64Vecs.encodePoints(vecModels),
|
|
}
|
|
})
|
|
props.scaleX = props.scaleX ?? 1
|
|
props.scaleY = props.scaleY ?? 1
|
|
},
|
|
down: (props) => {
|
|
// Convert delta-encoded 'path' back to VecModel[] arrays in 'points'
|
|
props.segments = props.segments.map((segment: any) => {
|
|
const { path, ...rest } = segment
|
|
return {
|
|
...rest,
|
|
points: b64Vecs.decodePoints(path),
|
|
}
|
|
})
|
|
delete props.scaleX
|
|
delete props.scaleY
|
|
},
|
|
},
|
|
{
|
|
id: Versions.LegacyPointsConversion,
|
|
up: (props) => {
|
|
// Handle legacy data that was already migrated to v3 with absolute Float16 in 'points'
|
|
// Convert 'points' to delta-encoded 'path'
|
|
props.segments = props.segments.map((segment: any) => {
|
|
// If segment already has 'path', it's already in the new format
|
|
if (segment.path !== undefined) return segment
|
|
|
|
const { points, ...rest } = segment
|
|
const vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)
|
|
return {
|
|
...rest,
|
|
path: b64Vecs.encodePoints(vecModels),
|
|
}
|
|
})
|
|
},
|
|
down: (_props) => {
|
|
// handled by the previous down migration
|
|
},
|
|
},
|
|
{
|
|
id: Versions.OmitNonPressureZ,
|
|
up: (_props) => {
|
|
// No-op. Pre-existing segments have no `dim` field, which is exactly how
|
|
// the new schema reads them (3D). The validator now accepts the optional
|
|
// `dim`, so newer 2D segments validate as well — nothing to rewrite here.
|
|
},
|
|
down: (props) => {
|
|
// Older clients only understand 3D paths and reject the unknown `dim` field.
|
|
// Strip `dim` from every segment that carries it; a 2D segment is also
|
|
// re-encoded back to 3D (decode supplies z = 0.5), while a `dim: 3` segment
|
|
// already has a 3D path so we just drop the field.
|
|
props.segments = props.segments.map((segment: any) => {
|
|
if (segment.dim === undefined) return segment
|
|
const { dim, ...rest } = segment
|
|
return dim === DIM_2D
|
|
? { ...rest, path: b64Vecs.encodePoints(b64Vecs.decodePoints(segment.path, DIM_2D)) }
|
|
: rest
|
|
})
|
|
},
|
|
},
|
|
],
|
|
})
|
|
|
|
/**
|
|
* Compress legacy draw shape segments by converting VecModel[] points to delta-encoded base64 format.
|
|
* This function is useful for converting old draw shape data to the new compressed format.
|
|
* Uses delta encoding for improved Float16 precision.
|
|
*
|
|
* @public
|
|
*/
|
|
export function compressLegacySegments(
|
|
segments: {
|
|
type: 'free' | 'straight'
|
|
points: VecModel[]
|
|
}[]
|
|
): TLDrawShapeSegment[] {
|
|
return segments.map((segment) => ({
|
|
type: segment.type,
|
|
path: b64Vecs.encodePoints(segment.points),
|
|
}))
|
|
}
|