UNPKG

tldraw

Version:

A tiny little drawing editor.

523 lines (468 loc) • 13.5 kB
import { AssetRecordType, Box, Editor, TLArrowShapeArrowheadStyle, TLAssetId, TLContent, TLDefaultColorStyle, TLDefaultDashStyle, TLDefaultFillStyle, TLDefaultFontStyle, TLDefaultHorizontalAlignStyle, TLDefaultSizeStyle, TLDefaultTextAlignStyle, TLOpacityType, TLShapeId, Vec, VecLike, ZERO_INDEX_KEY, compact, createBindingId, createShapeId, getIndexAbove, getIndices, isShapeId, toRichText, } from '@tldraw/editor' /** * Put excalidraw clipboard content onto the current page. * * @param editor - The editor instance. * @param excalidrawClipboardContent - The excalidraw clipboard model. * @param point - The point at which to put the excalidraw content. * @public */ export async function putExcalidrawContent( editor: Editor, excalidrawClipboardContent: any, point?: VecLike ) { const { elements, files } = excalidrawClipboardContent const tldrawContent: TLContent = { shapes: [], bindings: [], rootShapeIds: [], assets: [], schema: editor.store.schema.serialize(), } const groupShapeIdToChildren = new Map<string, TLShapeId[]>() const rotatedElements = new Map<TLShapeId, number>() const currentPageId = editor.getCurrentPageId() const excElementIdsToTldrawShapeIds = new Map<string, TLShapeId>() const rootShapeIds: TLShapeId[] = [] const skipIds = new Set<string>() elements.forEach((element: any) => { excElementIdsToTldrawShapeIds.set(element.id, createShapeId()) if (element.boundElements !== null) { for (const boundElement of element.boundElements) { if (boundElement.type === 'text') { skipIds.add(boundElement.id) } } } }) let index = ZERO_INDEX_KEY for (const element of elements) { if (skipIds.has(element.id)) { continue } const id = excElementIdsToTldrawShapeIds.get(element.id)! const base = { id, typeName: 'shape', parentId: currentPageId, index, x: element.x, y: element.y, rotation: 0, isLocked: element.locked, opacity: getOpacity(element.opacity), meta: {}, } as const if (element.angle !== 0) { rotatedElements.set(id, element.angle) } if (element.groupIds && element.groupIds.length > 0) { if (groupShapeIdToChildren.has(element.groupIds[0])) { groupShapeIdToChildren.get(element.groupIds[0])?.push(id) } else { groupShapeIdToChildren.set(element.groupIds[0], [id]) } } else { rootShapeIds.push(id) } switch (element.type) { case 'rectangle': case 'ellipse': case 'diamond': { let text = '' let align: TLDefaultHorizontalAlignStyle = 'middle' if (element.boundElements !== null) { for (const boundElement of element.boundElements) { if (boundElement.type === 'text') { const labelElement = elements.find((elm: any) => elm.id === boundElement.id) if (labelElement) { text = labelElement.text align = textAlignToAlignTypes[labelElement.textAlign] } } } } const colorToUse = element.backgroundColor === 'transparent' ? element.strokeColor : element.backgroundColor tldrawContent.shapes.push({ ...base, type: 'geo', props: { geo: element.type, url: element.link ?? '', w: element.width, h: element.height, size: strokeWidthsToSizes[element.strokeWidth] ?? 'draw', color: colorsToColors[colorToUse] ?? 'black', richText: toRichText(text), align, dash: getDash(element), fill: getFill(element), }, }) break } case 'freedraw': { tldrawContent.shapes.push({ ...base, type: 'draw', props: { dash: getDash(element), size: strokeWidthsToSizes[element.strokeWidth], color: colorsToColors[element.strokeColor] ?? 'black', segments: [ { type: 'free', points: element.points.map(([x, y, z = 0.5]: number[]) => ({ x, y, z, })), }, ], }, }) break } case 'line': { const points = element.points.slice() if (points.length < 2) { break } const indices = getIndices(element.points.length) tldrawContent.shapes.push({ ...base, type: 'line', props: { dash: getDash(element), size: strokeWidthsToSizes[element.strokeWidth], color: colorsToColors[element.strokeColor] ?? 'black', spline: element.roundness ? 'cubic' : 'line', points: { ...Object.fromEntries( element.points.map(([x, y]: number[], i: number) => { const index = indices[i] return [index, { id: index, index, x, y }] }) ), }, }, }) break } case 'arrow': { let text = '' if (element.boundElements !== null) { for (const boundElement of element.boundElements) { if (boundElement.type === 'text') { const labelElement = elements.find((elm: any) => elm.id === boundElement.id) if (labelElement) { text = labelElement.text } } } } const start = element.points[0] const end = element.points[element.points.length - 1] const startTargetId = excElementIdsToTldrawShapeIds.get(element.startBinding?.elementId) const endTargetId = excElementIdsToTldrawShapeIds.get(element.endBinding?.elementId) tldrawContent.shapes.push({ ...base, type: 'arrow', props: { text, kind: element.elbowed ? 'elbow' : 'arc', bend: getBend(element, start, end), dash: getDash(element), size: strokeWidthsToSizes[element.strokeWidth] ?? 'm', color: colorsToColors[element.strokeColor] ?? 'black', start: { x: start[0], y: start[1] }, end: { x: end[0], y: end[1] }, arrowheadEnd: arrowheadsToArrowheadTypes[element.endArrowhead] ?? 'none', arrowheadStart: arrowheadsToArrowheadTypes[element.startArrowhead] ?? 'none', }, }) if (startTargetId) { tldrawContent.bindings!.push({ id: createBindingId(), typeName: 'binding', type: 'arrow', fromId: id, toId: startTargetId, props: { terminal: 'start', normalizedAnchor: { x: 0.5, y: 0.5 }, isPrecise: false, isExact: false, }, meta: {}, }) } if (endTargetId) { tldrawContent.bindings!.push({ id: createBindingId(), typeName: 'binding', type: 'arrow', fromId: id, toId: endTargetId, props: { terminal: 'end', normalizedAnchor: { x: 0.5, y: 0.5 }, isPrecise: false, isExact: false, }, meta: {}, }) } break } case 'text': { const { size, scale } = getFontSizeAndScale(element.fontSize) tldrawContent.shapes.push({ ...base, type: 'text', props: { size, scale, font: fontFamilyToFontType[element.fontFamily] ?? 'draw', color: colorsToColors[element.strokeColor] ?? 'black', richText: toRichText(element.text), textAlign: textAlignToTextAlignTypes[element.textAlign], }, }) break } case 'image': { const file = files[element.fileId] if (!file) break const assetId: TLAssetId = AssetRecordType.createId() tldrawContent.assets.push({ id: assetId, typeName: 'asset', type: 'image', props: { w: element.width, h: element.height, fileSize: file.size, name: element.id ?? 'Untitled', isAnimated: false, mimeType: file.mimeType, src: file.dataURL, }, meta: {}, }) tldrawContent.shapes.push({ ...base, type: 'image', props: { w: element.width, h: element.height, assetId, }, }) } } index = getIndexAbove(index) } const p = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : undefined) editor.putContentOntoCurrentPage(tldrawContent, { point: p, select: false, preserveIds: true, }) for (const groupedShapeIds of groupShapeIdToChildren.values()) { if (groupedShapeIds.length > 1) { editor.groupShapes(groupedShapeIds) const groupShape = editor.getShape(groupedShapeIds[0]) if (groupShape?.parentId && isShapeId(groupShape.parentId)) { rootShapeIds.push(groupShape.parentId) } } } for (const [id, angle] of rotatedElements) { editor.select(id) editor.rotateShapesBy([id], angle) } const rootShapes = compact(rootShapeIds.map((id) => editor.getShape(id))) const bounds = Box.Common(rootShapes.map((s) => editor.getShapePageBounds(s)!)) const viewPortCenter = editor.getViewportPageBounds().center editor.updateShapes( rootShapes.map((s) => { const delta = { x: (s.x ?? 0) - (bounds.x + bounds.w / 2), y: (s.y ?? 0) - (bounds.y + bounds.h / 2), } return { id: s.id, type: s.type, x: viewPortCenter.x + delta.x, y: viewPortCenter.y + delta.y, } }) ) editor.setSelectedShapes(rootShapeIds) } /* --------------- Translating Helpers --------_------ */ const getOpacity = (opacity: number): TLOpacityType => { const t = opacity / 100 if (t < 0.2) { return 0.1 } else if (t < 0.4) { return 0.25 } else if (t < 0.6) { return 0.5 } else if (t < 0.8) { return 0.75 } return 1 } const strokeWidthsToSizes: Record<number, TLDefaultSizeStyle> = { 1: 's', 2: 'm', 3: 'l', 4: 'xl', } const fontSizesToSizes: Record<number, TLDefaultSizeStyle> = { 16: 's', 20: 'm', 28: 'l', 36: 'xl', } function getFontSizeAndScale(fontSize: number): { size: TLDefaultSizeStyle; scale: number } { const size = fontSizesToSizes[fontSize] if (size) { return { size, scale: 1 } } if (fontSize < 16) { return { size: 's', scale: fontSize / 16 } } if (fontSize > 36) { return { size: 'xl', scale: fontSize / 36 } } return { size: 'm', scale: 1 } } const fontFamilyToFontType: Record<number, TLDefaultFontStyle> = { 1: 'draw', 2: 'sans', 3: 'mono', } const oc = { gray: ['#f8f9fa', '#e9ecef', '#ced4da', '#868e96', '#343a40'], red: ['#fff5f5', '#ffc9c9', '#ff8787', '#fa5252', '#e03131'], pink: ['#fff0f6', '#fcc2d7', '#f783ac', '#e64980', '#c2255c'], grape: ['#f8f0fc', '#eebefa', '#da77f2', '#be4bdb', '#9c36b5'], violet: ['#f3f0ff', '#d0bfff', '#9775fa', '#7950f2', '#6741d9'], indigo: ['#edf2ff', '#bac8ff', '#748ffc', '#4c6ef5', '#3b5bdb'], blue: ['#e7f5ff', '#a5d8ff', '#4dabf7', '#228be6', '#1971c2'], cyan: ['#e3fafc', '#99e9f2', '#3bc9db', '#15aabf', '#0c8599'], teal: ['#e6fcf5', '#96f2d7', '#38d9a9', '#12b886', '#099268'], green: ['#ebfbee', '#b2f2bb', '#69db7c', '#40c057', '#2f9e44'], lime: ['#f4fce3', '#d8f5a2', '#a9e34b', '#82c91e', '#66a80f'], yellow: ['#fff9db', '#ffec99', '#ffd43b', '#fab005', '#f08c00'], orange: ['#fff4e6', '#ffd8a8', '#ffa94d', '#fd7e14', '#e8590c'], } function mapExcalidrawColorToTldrawColors( excalidrawColor: keyof typeof oc, light: TLDefaultColorStyle, dark: TLDefaultColorStyle ) { const colors = [0, 1, 2, 3, 4].map((index) => oc[excalidrawColor][index]) return Object.fromEntries(colors.map((c, i) => [c, i < 3 ? light : dark])) } const colorsToColors: Record<string, TLDefaultColorStyle> = { ...mapExcalidrawColorToTldrawColors('gray', 'grey', 'black'), ...mapExcalidrawColorToTldrawColors('red', 'light-red', 'red'), ...mapExcalidrawColorToTldrawColors('pink', 'light-red', 'red'), ...mapExcalidrawColorToTldrawColors('grape', 'light-violet', 'violet'), ...mapExcalidrawColorToTldrawColors('blue', 'light-blue', 'blue'), ...mapExcalidrawColorToTldrawColors('cyan', 'light-blue', 'blue'), ...mapExcalidrawColorToTldrawColors('teal', 'light-green', 'green'), ...mapExcalidrawColorToTldrawColors('green', 'light-green', 'green'), ...mapExcalidrawColorToTldrawColors('yellow', 'yellow', 'orange'), ...mapExcalidrawColorToTldrawColors('orange', 'yellow', 'orange'), '#ffffff': 'white', '#000000': 'black', } const strokeStylesToStrokeTypes: Record<string, TLDefaultDashStyle> = { solid: 'draw', dashed: 'dashed', dotted: 'dotted', } const fillStylesToFillType: Record<string, TLDefaultFillStyle> = { 'cross-hatch': 'pattern', hachure: 'pattern', solid: 'solid', } const textAlignToAlignTypes: Record<string, TLDefaultHorizontalAlignStyle> = { left: 'start', center: 'middle', right: 'end', } const textAlignToTextAlignTypes: Record<string, TLDefaultTextAlignStyle> = { left: 'start', center: 'middle', right: 'end', } const arrowheadsToArrowheadTypes: Record<string, TLArrowShapeArrowheadStyle> = { arrow: 'arrow', dot: 'dot', triangle: 'triangle', bar: 'pipe', } function getBend(element: any, startPoint: any, endPoint: any) { let bend = 0 if (element.points.length > 2) { const start = new Vec(startPoint[0], startPoint[1]) const end = new Vec(endPoint[0], endPoint[1]) const handle = new Vec(element.points[1][0], element.points[1][1]) const delta = Vec.Sub(end, start) const v = Vec.Per(delta) const med = Vec.Med(end, start) const A = Vec.Sub(med, v) const B = Vec.Add(med, v) const point = Vec.NearestPointOnLineSegment(A, B, handle, false) bend = Vec.Dist(point, med) if (Vec.Clockwise(point, end, med)) bend *= -1 } return bend } const getDash = (element: any): TLDefaultDashStyle => { let dash: TLDefaultDashStyle = strokeStylesToStrokeTypes[element.strokeStyle] ?? 'draw' if (dash === 'draw' && element.roughness === 0) { dash = 'solid' } return dash } const getFill = (element: any): TLDefaultFillStyle => { if (element.backgroundColor === 'transparent') { return 'none' } return fillStylesToFillType[element.fillStyle] ?? 'solid' }