[improvement] Add basic support for copying text (#354)
* Add getSvgElement * Update TextUtil.tsx * Add sticky svg * Fix bounds bug, improve text export * Include fonts
This commit is contained in:
parent
f8dc5b3248
commit
a2fff9dca7
8 changed files with 101 additions and 29 deletions
|
@ -9,7 +9,7 @@ export function useBoundsEvents() {
|
||||||
if (!inputs.pointerIsValid(e)) return
|
if (!inputs.pointerIsValid(e)) return
|
||||||
|
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
callbacks.onRightPointShape?.(inputs.pointerDown(e, 'bounds'), e)
|
callbacks.onRightPointBounds?.(inputs.pointerDown(e, 'bounds'), e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1558,26 +1558,32 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
copySvg = (ids = this.selectedIds, pageId = this.currentPageId) => {
|
copySvg = (ids = this.selectedIds, pageId = this.currentPageId) => {
|
||||||
if (ids.length === 0) ids = Object.keys(this.page.shapes)
|
if (ids.length === 0) ids = Object.keys(this.page.shapes)
|
||||||
|
|
||||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
||||||
|
|
||||||
const shapes = ids.map((id) => this.getShape(id, pageId))
|
const shapes = ids.map((id) => this.getShape(id, pageId))
|
||||||
|
const commonBounds = Utils.getCommonBounds(shapes.map(TLDR.getRotatedBounds))
|
||||||
|
const padding = 16
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
|
||||||
|
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
|
||||||
|
|
||||||
|
style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');`
|
||||||
|
defs.appendChild(style)
|
||||||
|
svg.appendChild(defs)
|
||||||
|
|
||||||
function getSvgElementForShape(shape: TDShape) {
|
function getSvgElementForShape(shape: TDShape) {
|
||||||
const elm = document.getElementById(shape.id + '_svg')
|
const util = TLDR.getShapeUtil(shape)
|
||||||
|
const element = util.getSvgElement(shape)
|
||||||
|
const bounds = util.getBounds(shape)
|
||||||
|
|
||||||
if (!elm) return
|
if (!element) return
|
||||||
|
|
||||||
// TODO: Create SVG elements for text
|
|
||||||
|
|
||||||
const element = elm?.cloneNode(true) as SVGElement
|
|
||||||
|
|
||||||
const bounds = TLDR.getShapeUtil(shape).getBounds(shape)
|
|
||||||
|
|
||||||
element.setAttribute(
|
element.setAttribute(
|
||||||
'transform',
|
'transform',
|
||||||
`translate(${shape.point[0]}, ${shape.point[1]}) rotate(${
|
`translate(${padding + shape.point[0] - commonBounds.minX}, ${
|
||||||
((shape.rotation || 0) * 180) / Math.PI
|
padding + shape.point[1] - commonBounds.minY
|
||||||
}, ${bounds.width / 2}, ${bounds.height / 2})`
|
}) rotate(${((shape.rotation || 0) * 180) / Math.PI}, ${bounds.width / 2}, ${
|
||||||
|
bounds.height / 2
|
||||||
|
})`
|
||||||
)
|
)
|
||||||
|
|
||||||
return element
|
return element
|
||||||
|
@ -1608,23 +1614,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const bounds = Utils.getCommonBounds(shapes.map(TLDR.getRotatedBounds))
|
|
||||||
const padding = 16
|
|
||||||
|
|
||||||
// Resize the element to the bounding box
|
// Resize the element to the bounding box
|
||||||
svg.setAttribute(
|
svg.setAttribute(
|
||||||
'viewBox',
|
'viewBox',
|
||||||
[
|
[0, 0, commonBounds.width + padding * 2, commonBounds.height + padding * 2].join(' ')
|
||||||
bounds.minX - padding,
|
|
||||||
bounds.minY - padding,
|
|
||||||
bounds.width + padding * 2,
|
|
||||||
bounds.height + padding * 2,
|
|
||||||
].join(' ')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
svg.setAttribute('width', String(bounds.width))
|
svg.setAttribute('width', String(commonBounds.width))
|
||||||
|
svg.setAttribute('height', String(commonBounds.height))
|
||||||
svg.setAttribute('height', String(bounds.height))
|
|
||||||
|
|
||||||
const s = new XMLSerializer()
|
const s = new XMLSerializer()
|
||||||
|
|
||||||
|
|
|
@ -202,6 +202,6 @@ Array [
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"-16 -16 232 232\\" width=\\"200\\" height=\\"200\\"><g/></svg>"`;
|
exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');</style></defs><g/></svg>"`;
|
||||||
|
|
||||||
exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"-20.741879096242684 -20.741879096242684 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\"/>"`;
|
exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');</style></defs></svg>"`;
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { styled } from '~styles'
|
||||||
import { Vec } from '@tldraw/vec'
|
import { Vec } from '@tldraw/vec'
|
||||||
import { GHOSTED_OPACITY } from '~constants'
|
import { GHOSTED_OPACITY } from '~constants'
|
||||||
import { TLDR } from '~state/TLDR'
|
import { TLDR } from '~state/TLDR'
|
||||||
|
import { getTextSvgElement } from '../shared/getTextSvgElement'
|
||||||
|
|
||||||
type T = StickyShape
|
type T = StickyShape
|
||||||
type E = HTMLDivElement
|
type E = HTMLDivElement
|
||||||
|
@ -232,6 +233,24 @@ export class StickyUtil extends TDShapeUtil<T, E> {
|
||||||
transformSingle = (shape: T): Partial<T> => {
|
transformSingle = (shape: T): Partial<T> => {
|
||||||
return shape
|
return shape
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSvgElement = (shape: T): SVGElement | void => {
|
||||||
|
const bounds = this.getBounds(shape)
|
||||||
|
const textElm = getTextSvgElement(shape, bounds)
|
||||||
|
const style = getStickyShapeStyle(shape.style)
|
||||||
|
textElm.setAttribute('fill', style.color)
|
||||||
|
|
||||||
|
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', bounds.width + '')
|
||||||
|
rect.setAttribute('height', bounds.height + '')
|
||||||
|
rect.setAttribute('fill', style.fill)
|
||||||
|
|
||||||
|
g.appendChild(rect)
|
||||||
|
g.appendChild(textElm)
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------- */
|
/* -------------------------------------------------- */
|
||||||
|
|
|
@ -179,4 +179,8 @@ export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> ex
|
||||||
onDoubleClickBoundsHandle?: (shape: T) => Partial<T> | void
|
onDoubleClickBoundsHandle?: (shape: T) => Partial<T> | void
|
||||||
|
|
||||||
onSessionComplete?: (shape: T) => Partial<T> | void
|
onSessionComplete?: (shape: T) => Partial<T> | void
|
||||||
|
|
||||||
|
getSvgElement = (shape: T): SVGElement | void => {
|
||||||
|
return document.getElementById(shape.id + '_svg')?.cloneNode(true) as SVGElement
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { styled } from '~styles'
|
||||||
import { Vec } from '@tldraw/vec'
|
import { Vec } from '@tldraw/vec'
|
||||||
import { TLDR } from '~state/TLDR'
|
import { TLDR } from '~state/TLDR'
|
||||||
import { getTextAlign } from '../shared/getTextAlign'
|
import { getTextAlign } from '../shared/getTextAlign'
|
||||||
|
import { getTextSvgElement } from '../shared/getTextSvgElement'
|
||||||
|
|
||||||
type T = TextShape
|
type T = TextShape
|
||||||
type E = HTMLDivElement
|
type E = HTMLDivElement
|
||||||
|
@ -311,6 +312,13 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
||||||
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
|
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSvgElement = (shape: T): SVGElement | void => {
|
||||||
|
const bounds = this.getBounds(shape)
|
||||||
|
const elm = getTextSvgElement(shape, bounds)
|
||||||
|
elm.setAttribute('fill', getShapeStyle(shape.style).stroke)
|
||||||
|
return elm
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------- */
|
/* -------------------------------------------------- */
|
||||||
|
@ -332,7 +340,7 @@ function getMeasurementDiv() {
|
||||||
Object.assign(pre.style, {
|
Object.assign(pre.style, {
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
border: '1px solid red',
|
border: '1px solid transparent',
|
||||||
padding: '4px',
|
padding: '4px',
|
||||||
margin: '0px',
|
margin: '0px',
|
||||||
letterSpacing: `${LETTER_SPACING}px`,
|
letterSpacing: `${LETTER_SPACING}px`,
|
||||||
|
|
43
packages/tldraw/src/state/shapes/shared/getTextSvgElement.ts
Normal file
43
packages/tldraw/src/state/shapes/shared/getTextSvgElement.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import type { TLBounds } from '@tldraw/core'
|
||||||
|
import { AlignStyle, StickyShape, TextShape } from '~types'
|
||||||
|
import { getFontFace, getFontSize } from './shape-styles'
|
||||||
|
import { getTextAlign } from './getTextAlign'
|
||||||
|
|
||||||
|
export function getTextSvgElement(shape: TextShape | StickyShape, bounds: TLBounds) {
|
||||||
|
const { text, style } = shape
|
||||||
|
const fontSize = getFontSize(shape.style.size, shape.style.font)
|
||||||
|
|
||||||
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
|
||||||
|
|
||||||
|
const LINE_HEIGHT = fontSize * 1.3
|
||||||
|
|
||||||
|
const textLines = text.split('\n').map((line, i) => {
|
||||||
|
const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
||||||
|
textElm.textContent = line
|
||||||
|
textElm.setAttribute('font-family', getFontFace(style.font))
|
||||||
|
textElm.setAttribute('font-size', fontSize + 'px')
|
||||||
|
textElm.setAttribute('text-anchor', 'start')
|
||||||
|
textElm.setAttribute('alignment-baseline', 'central')
|
||||||
|
textElm.setAttribute('text-align', getTextAlign(style.textAlign))
|
||||||
|
textElm.setAttribute('y', LINE_HEIGHT * (0.5 + i) + '')
|
||||||
|
g.appendChild(textElm)
|
||||||
|
|
||||||
|
return textElm
|
||||||
|
})
|
||||||
|
|
||||||
|
if (style.textAlign === AlignStyle.Middle) {
|
||||||
|
textLines.forEach((textElm) => {
|
||||||
|
textElm.setAttribute('x', bounds.width / 2 + '')
|
||||||
|
textElm.setAttribute('text-align', 'center')
|
||||||
|
textElm.setAttribute('text-anchor', 'middle')
|
||||||
|
})
|
||||||
|
} else if (style.textAlign === AlignStyle.End) {
|
||||||
|
textLines.forEach((textElm) => {
|
||||||
|
textElm.setAttribute('x', bounds.width + '')
|
||||||
|
textElm.setAttribute('text-align', 'right')
|
||||||
|
textElm.setAttribute('text-anchor', 'end')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
|
@ -551,6 +551,7 @@ export class SelectTool extends BaseTool<Status> {
|
||||||
|
|
||||||
onRightPointShape: TLPointerEventHandler = (info) => {
|
onRightPointShape: TLPointerEventHandler = (info) => {
|
||||||
if (!this.app.isSelected(info.target)) {
|
if (!this.app.isSelected(info.target)) {
|
||||||
|
console.log(info.target)
|
||||||
this.app.select(info.target)
|
this.app.select(info.target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue