kopia lustrzana https://github.com/Tldraw/Tldraw
509 wiersze
12 KiB
TypeScript
509 wiersze
12 KiB
TypeScript
import {
|
|
AssetRecordType,
|
|
Editor,
|
|
TLAlignType,
|
|
TLArrowheadType,
|
|
TLAssetId,
|
|
TLColorType,
|
|
TLContent,
|
|
TLDashType,
|
|
TLFillType,
|
|
TLFontType,
|
|
TLOpacityType,
|
|
TLShapeId,
|
|
TLSizeType,
|
|
createShapeId,
|
|
getIndexAbove,
|
|
getIndices,
|
|
isShapeId,
|
|
uniqueId,
|
|
} from '@tldraw/editor'
|
|
import { Box2d, Vec2d, VecLike } from '@tldraw/primitives'
|
|
import { compact } from '@tldraw/utils'
|
|
|
|
/**
|
|
* When the clipboard has excalidraw content, paste it into the scene.
|
|
*
|
|
* @param editor - The editor instance.
|
|
* @param clipboard - The clipboard model.
|
|
* @param point - (optional) The point at which to paste the text.
|
|
* @internal
|
|
*/
|
|
export async function pasteExcalidrawContent(editor: Editor, clipboard: any, point?: VecLike) {
|
|
const { elements, files } = clipboard
|
|
|
|
const tldrawContent: TLContent = {
|
|
shapes: [],
|
|
rootShapeIds: [],
|
|
assets: [],
|
|
schema: editor.store.schema.serialize(),
|
|
}
|
|
|
|
const groupShapeIdToChildren = new Map<string, TLShapeId[]>()
|
|
const rotatedElements = new Map<TLShapeId, number>()
|
|
|
|
const { currentPageId } = editor
|
|
|
|
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 = 'a1'
|
|
|
|
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),
|
|
} 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: TLAlignType = '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',
|
|
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 start = element.points[0]
|
|
const end = element.points[element.points.length - 1]
|
|
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',
|
|
handles: {
|
|
start: {
|
|
id: 'start',
|
|
type: 'vertex',
|
|
index: indices[0],
|
|
x: start[0],
|
|
y: start[1],
|
|
},
|
|
end: {
|
|
id: 'end',
|
|
type: 'vertex',
|
|
index: indices[indices.length - 1],
|
|
x: end[0],
|
|
y: end[1],
|
|
},
|
|
...Object.fromEntries(
|
|
element.points.slice(1, -1).map(([x, y]: number[], i: number) => {
|
|
const id = uniqueId()
|
|
return [
|
|
id,
|
|
{
|
|
id,
|
|
type: 'vertex',
|
|
index: indices[i + 1],
|
|
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,
|
|
bend: getBend(element, start, end),
|
|
dash: getDash(element),
|
|
size: strokeWidthsToSizes[element.strokeWidth] ?? 'm',
|
|
color: colorsToColors[element.strokeColor] ?? 'black',
|
|
start: startTargetId
|
|
? {
|
|
type: 'binding',
|
|
boundShapeId: startTargetId,
|
|
normalizedAnchor: { x: 0.5, y: 0.5 },
|
|
isExact: false,
|
|
}
|
|
: {
|
|
type: 'point',
|
|
x: start[0],
|
|
y: start[1],
|
|
},
|
|
end: endTargetId
|
|
? {
|
|
type: 'binding',
|
|
boundShapeId: endTargetId,
|
|
normalizedAnchor: { x: 0.5, y: 0.5 },
|
|
isExact: false,
|
|
}
|
|
: {
|
|
type: 'point',
|
|
x: end[0],
|
|
y: end[1],
|
|
},
|
|
arrowheadEnd: arrowheadsToArrowheadTypes[element.endArrowhead] ?? 'none',
|
|
arrowheadStart: arrowheadsToArrowheadTypes[element.startArrowhead] ?? 'none',
|
|
},
|
|
})
|
|
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',
|
|
text: element.text,
|
|
align: textAlignToAlignTypes[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,
|
|
name: element.id ?? 'Untitled',
|
|
isAnimated: false,
|
|
mimeType: file.mimeType,
|
|
src: file.dataURL,
|
|
},
|
|
})
|
|
|
|
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.mark('paste')
|
|
|
|
editor.putContent(tldrawContent, {
|
|
point: p,
|
|
select: false,
|
|
preserveIds: true,
|
|
})
|
|
for (const groupedShapeIds of groupShapeIdToChildren.values()) {
|
|
if (groupedShapeIds.length > 1) {
|
|
editor.groupShapes(groupedShapeIds)
|
|
const groupShape = editor.getShapeById(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.getShapeById(id)))
|
|
const bounds = Box2d.Common(rootShapes.map((s) => editor.getPageBounds(s)!))
|
|
const viewPortCenter = editor.viewportPageBounds.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.setSelectedIds(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, TLSizeType> = {
|
|
1: 's',
|
|
2: 'm',
|
|
3: 'l',
|
|
4: 'xl',
|
|
}
|
|
|
|
const fontSizesToSizes: Record<number, TLSizeType> = {
|
|
16: 's',
|
|
20: 'm',
|
|
28: 'l',
|
|
36: 'xl',
|
|
}
|
|
|
|
function getFontSizeAndScale(fontSize: number): { size: TLSizeType; 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, TLFontType> = {
|
|
1: 'draw',
|
|
2: 'sans',
|
|
3: 'mono',
|
|
}
|
|
|
|
const colorsToColors: Record<string, TLColorType> = {
|
|
'#ffffff': 'grey',
|
|
// Strokes
|
|
'#000000': 'black',
|
|
'#343a40': 'black',
|
|
'#495057': 'grey',
|
|
'#c92a2a': 'red',
|
|
'#a61e4d': 'light-red',
|
|
'#862e9c': 'violet',
|
|
'#5f3dc4': 'light-violet',
|
|
'#364fc7': 'blue',
|
|
'#1864ab': 'light-blue',
|
|
'#0b7285': 'light-green',
|
|
'#087f5b': 'light-green',
|
|
'#2b8a3e': 'green',
|
|
'#5c940d': 'light-green',
|
|
'#e67700': 'yellow',
|
|
'#d9480f': 'orange',
|
|
// Backgrounds
|
|
'#ced4da': 'grey',
|
|
'#868e96': 'grey',
|
|
'#fa5252': 'light-red',
|
|
'#e64980': 'red',
|
|
'#be4bdb': 'light-violet',
|
|
'#7950f2': 'violet',
|
|
'#4c6ef5': 'blue',
|
|
'#228be6': 'light-blue',
|
|
'#15aabf': 'light-green',
|
|
'#12b886': 'green',
|
|
'#40c057': 'green',
|
|
'#82c91e': 'light-green',
|
|
'#fab005': 'yellow',
|
|
'#fd7e14': 'orange',
|
|
'#212529': 'grey',
|
|
}
|
|
|
|
const strokeStylesToStrokeTypes: Record<string, TLDashType> = {
|
|
solid: 'draw',
|
|
dashed: 'dashed',
|
|
dotted: 'dotted',
|
|
}
|
|
|
|
const fillStylesToFillType: Record<string, TLFillType> = {
|
|
'cross-hatch': 'pattern',
|
|
hachure: 'pattern',
|
|
solid: 'solid',
|
|
}
|
|
|
|
const textAlignToAlignTypes: Record<string, TLAlignType> = {
|
|
left: 'start',
|
|
center: 'middle',
|
|
right: 'end',
|
|
}
|
|
|
|
const arrowheadsToArrowheadTypes: Record<string, TLArrowheadType> = {
|
|
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 Vec2d(startPoint[0], startPoint[1])
|
|
const end = new Vec2d(endPoint[0], endPoint[1])
|
|
const handle = new Vec2d(element.points[1][0], element.points[1][1])
|
|
const delta = Vec2d.Sub(end, start)
|
|
const v = Vec2d.Per(delta)
|
|
|
|
const med = Vec2d.Med(end, start)
|
|
const A = Vec2d.Sub(med, v)
|
|
const B = Vec2d.Add(med, v)
|
|
|
|
const point = Vec2d.NearestPointOnLineSegment(A, B, handle, false)
|
|
bend = Vec2d.Dist(point, med)
|
|
if (Vec2d.Clockwise(point, end, med)) bend *= -1
|
|
}
|
|
return bend
|
|
}
|
|
|
|
const getDash = (element: any): TLDashType => {
|
|
let dash: TLDashType = strokeStylesToStrokeTypes[element.strokeStyle] ?? 'draw'
|
|
if (dash === 'draw' && element.roughness === 0) {
|
|
dash = 'solid'
|
|
}
|
|
return dash
|
|
}
|
|
|
|
const getFill = (element: any): TLFillType => {
|
|
if (element.backgroundColor === 'transparent') {
|
|
return 'none'
|
|
}
|
|
return fillStylesToFillType[element.fillStyle] ?? 'solid'
|
|
}
|