From a2fff9dca7b19f6789407441ed2db4c96c225a2c Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Mon, 22 Nov 2021 16:15:51 +0000 Subject: [PATCH] [improvement] Add basic support for copying text (#354) * Add getSvgElement * Update TextUtil.tsx * Add sticky svg * Fix bounds bug, improve text export * Include fonts --- packages/core/src/hooks/useBoundsEvents.tsx | 2 +- packages/tldraw/src/state/TldrawApp.ts | 47 +++++++++---------- .../__snapshots__/TLDrawApp.spec.ts.snap | 4 +- .../state/shapes/StickyUtil/StickyUtil.tsx | 19 ++++++++ .../tldraw/src/state/shapes/TDShapeUtil.tsx | 4 ++ .../src/state/shapes/TextUtil/TextUtil.tsx | 10 +++- .../state/shapes/shared/getTextSvgElement.ts | 43 +++++++++++++++++ .../src/state/tools/SelectTool/SelectTool.ts | 1 + 8 files changed, 101 insertions(+), 29 deletions(-) create mode 100644 packages/tldraw/src/state/shapes/shared/getTextSvgElement.ts diff --git a/packages/core/src/hooks/useBoundsEvents.tsx b/packages/core/src/hooks/useBoundsEvents.tsx index 982d0bae2..4bb1dd456 100644 --- a/packages/core/src/hooks/useBoundsEvents.tsx +++ b/packages/core/src/hooks/useBoundsEvents.tsx @@ -9,7 +9,7 @@ export function useBoundsEvents() { if (!inputs.pointerIsValid(e)) return if (e.button === 2) { - callbacks.onRightPointShape?.(inputs.pointerDown(e, 'bounds'), e) + callbacks.onRightPointBounds?.(inputs.pointerDown(e, 'bounds'), e) return } diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index 2ef4ad575..8081c19f2 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -1558,26 +1558,32 @@ export class TldrawApp extends StateManager { copySvg = (ids = this.selectedIds, pageId = this.currentPageId) => { 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 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) { - 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 - - // TODO: Create SVG elements for text - - const element = elm?.cloneNode(true) as SVGElement - - const bounds = TLDR.getShapeUtil(shape).getBounds(shape) + if (!element) return element.setAttribute( 'transform', - `translate(${shape.point[0]}, ${shape.point[1]}) rotate(${ - ((shape.rotation || 0) * 180) / Math.PI - }, ${bounds.width / 2}, ${bounds.height / 2})` + `translate(${padding + shape.point[0] - commonBounds.minX}, ${ + padding + shape.point[1] - commonBounds.minY + }) rotate(${((shape.rotation || 0) * 180) / Math.PI}, ${bounds.width / 2}, ${ + bounds.height / 2 + })` ) return element @@ -1608,23 +1614,14 @@ export class TldrawApp extends StateManager { } }) - const bounds = Utils.getCommonBounds(shapes.map(TLDR.getRotatedBounds)) - const padding = 16 - // Resize the element to the bounding box svg.setAttribute( 'viewBox', - [ - bounds.minX - padding, - bounds.minY - padding, - bounds.width + padding * 2, - bounds.height + padding * 2, - ].join(' ') + [0, 0, commonBounds.width + padding * 2, commonBounds.height + padding * 2].join(' ') ) - svg.setAttribute('width', String(bounds.width)) - - svg.setAttribute('height', String(bounds.height)) + svg.setAttribute('width', String(commonBounds.width)) + svg.setAttribute('height', String(commonBounds.height)) const s = new XMLSerializer() diff --git a/packages/tldraw/src/state/__snapshots__/TLDrawApp.spec.ts.snap b/packages/tldraw/src/state/__snapshots__/TLDrawApp.spec.ts.snap index 59e77a8c2..663ed82f2 100644 --- a/packages/tldraw/src/state/__snapshots__/TLDrawApp.spec.ts.snap +++ b/packages/tldraw/src/state/__snapshots__/TLDrawApp.spec.ts.snap @@ -202,6 +202,6 @@ Array [ ] `; -exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `""`; +exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `""`; -exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `""`; +exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `""`; diff --git a/packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.tsx b/packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.tsx index b1b54dc47..27a0d4029 100644 --- a/packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.tsx +++ b/packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.tsx @@ -10,6 +10,7 @@ import { styled } from '~styles' import { Vec } from '@tldraw/vec' import { GHOSTED_OPACITY } from '~constants' import { TLDR } from '~state/TLDR' +import { getTextSvgElement } from '../shared/getTextSvgElement' type T = StickyShape type E = HTMLDivElement @@ -232,6 +233,24 @@ export class StickyUtil extends TDShapeUtil { transformSingle = (shape: T): Partial => { 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 + } } /* -------------------------------------------------- */ diff --git a/packages/tldraw/src/state/shapes/TDShapeUtil.tsx b/packages/tldraw/src/state/shapes/TDShapeUtil.tsx index 15f928f93..a2f2af611 100644 --- a/packages/tldraw/src/state/shapes/TDShapeUtil.tsx +++ b/packages/tldraw/src/state/shapes/TDShapeUtil.tsx @@ -179,4 +179,8 @@ export abstract class TDShapeUtil ex onDoubleClickBoundsHandle?: (shape: T) => Partial | void onSessionComplete?: (shape: T) => Partial | void + + getSvgElement = (shape: T): SVGElement | void => { + return document.getElementById(shape.id + '_svg')?.cloneNode(true) as SVGElement + } } diff --git a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx index cf04cbec5..f761a6cd7 100644 --- a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx +++ b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx @@ -10,6 +10,7 @@ import { styled } from '~styles' import { Vec } from '@tldraw/vec' import { TLDR } from '~state/TLDR' import { getTextAlign } from '../shared/getTextAlign' +import { getTextSvgElement } from '../shared/getTextSvgElement' type T = TextShape type E = HTMLDivElement @@ -311,6 +312,13 @@ export class TextUtil extends TDShapeUtil { 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, { whiteSpace: 'pre', width: 'auto', - border: '1px solid red', + border: '1px solid transparent', padding: '4px', margin: '0px', letterSpacing: `${LETTER_SPACING}px`, diff --git a/packages/tldraw/src/state/shapes/shared/getTextSvgElement.ts b/packages/tldraw/src/state/shapes/shared/getTextSvgElement.ts new file mode 100644 index 000000000..ff623e490 --- /dev/null +++ b/packages/tldraw/src/state/shapes/shared/getTextSvgElement.ts @@ -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 +} diff --git a/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts b/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts index b538eb57b..32982405b 100644 --- a/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts +++ b/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts @@ -551,6 +551,7 @@ export class SelectTool extends BaseTool { onRightPointShape: TLPointerEventHandler = (info) => { if (!this.app.isSelected(info.target)) { + console.log(info.target) this.app.select(info.target) } }