Adds copy to svg
This commit is contained in:
parent
124b70b262
commit
fc2e3b3c4c
9 changed files with 148 additions and 14 deletions
|
@ -30,10 +30,9 @@ export default function Canvas() {
|
||||||
<MainSVG ref={rCanvas} {...events}>
|
<MainSVG ref={rCanvas} {...events}>
|
||||||
<Defs />
|
<Defs />
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<g ref={rGroup}>
|
<g ref={rGroup} id="shapes">
|
||||||
<BoundsBg />
|
<BoundsBg />
|
||||||
<Page />
|
<Page />
|
||||||
{/* <Selected /> */}
|
|
||||||
<Bounds />
|
<Bounds />
|
||||||
<Handles />
|
<Handles />
|
||||||
<Brush />
|
<Brush />
|
||||||
|
|
|
@ -208,6 +208,14 @@ export default function ContextMenu({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MoveToPageMenu />
|
<MoveToPageMenu />
|
||||||
|
<Button onSelect={() => state.send('COPIED_TO_SVG')}>
|
||||||
|
<span>Copy to SVG</span>
|
||||||
|
<kbd>
|
||||||
|
<span>{commandKey()}</span>
|
||||||
|
<span>⇧</span>
|
||||||
|
<span>C</span>
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
<StyledDivider />
|
<StyledDivider />
|
||||||
<Button onSelect={() => state.send('DELETED')}>
|
<Button onSelect={() => state.send('DELETED')}>
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { getShapeStyle } from 'lib/shape-styles'
|
||||||
import { getShapeUtils } from 'lib/shape-utils'
|
import { getShapeUtils } from 'lib/shape-utils'
|
||||||
import { memo } from 'react'
|
import React, { memo } from 'react'
|
||||||
import { useSelector } from 'state'
|
import { useSelector } from 'state'
|
||||||
import { deepCompareArrays, getCurrentCamera, getPage } from 'utils/utils'
|
import { deepCompareArrays, getCurrentCamera, getPage } from 'utils/utils'
|
||||||
import { DotCircle, Handle } from './misc'
|
import { DotCircle, Handle } from './misc'
|
||||||
|
@ -33,5 +34,11 @@ const Def = memo(function Def({ id }: { id: string }) {
|
||||||
const shape = useSelector((s) => getPage(s.data).shapes[id])
|
const shape = useSelector((s) => getPage(s.data).shapes[id])
|
||||||
|
|
||||||
if (!shape) return null
|
if (!shape) return null
|
||||||
return getShapeUtils(shape).render(shape, { isEditing: false })
|
|
||||||
|
const style = getShapeStyle(shape.style)
|
||||||
|
|
||||||
|
return React.cloneElement(
|
||||||
|
getShapeUtils(shape).render(shape, { isEditing: false }),
|
||||||
|
{ ...style }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -32,11 +32,11 @@ export default function Selected() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShapeOutline = memo(function ShapeOutline({ id }: { id: string }) {
|
export const ShapeOutline = memo(function ShapeOutline({ id }: { id: string }) {
|
||||||
const rIndicator = useRef<SVGUseElement>(null)
|
// const rIndicator = useRef<SVGUseElement>(null)
|
||||||
|
|
||||||
const shape = useSelector((s) => getPage(s.data).shapes[id])
|
const shape = useSelector((s) => getPage(s.data).shapes[id])
|
||||||
|
|
||||||
const events = useShapeEvents(id, shape?.type === ShapeType.Group, rIndicator)
|
// const events = useShapeEvents(id, shape?.type === ShapeType.Group, rIndicator)
|
||||||
|
|
||||||
if (!shape) return null
|
if (!shape) return null
|
||||||
|
|
||||||
|
@ -52,23 +52,24 @@ export const ShapeOutline = memo(function ShapeOutline({ id }: { id: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectIndicator
|
<SelectIndicator
|
||||||
ref={rIndicator}
|
// ref={rIndicator}
|
||||||
as="use"
|
as="use"
|
||||||
href={'#' + id}
|
href={'#' + id}
|
||||||
transform={transform}
|
transform={transform}
|
||||||
isLocked={shape.isLocked}
|
isLocked={shape.isLocked}
|
||||||
{...events}
|
// {...events}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const SelectIndicator = styled('path', {
|
const SelectIndicator = styled('path', {
|
||||||
zStrokeWidth: 2,
|
// zStrokeWidth: 2,
|
||||||
strokeLineCap: 'round',
|
strokeLineCap: 'round',
|
||||||
strokeLinejoin: 'round',
|
strokeLinejoin: 'round',
|
||||||
stroke: '$selected',
|
stroke: 'red',
|
||||||
|
strokeWidth: '10',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
fill: 'transparent',
|
fill: 'red',
|
||||||
|
|
||||||
variants: {
|
variants: {
|
||||||
isLocked: {
|
isLocked: {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import state, { useSelector } from 'state'
|
||||||
import styled from 'styles'
|
import styled from 'styles'
|
||||||
import { getShapeUtils } from 'lib/shape-utils'
|
import { getShapeUtils } from 'lib/shape-utils'
|
||||||
import { getBoundsCenter, getPage, isMobile } from 'utils/utils'
|
import { getBoundsCenter, getPage, isMobile } from 'utils/utils'
|
||||||
import { ShapeStyles, ShapeType } from 'types'
|
import { ShapeStyles, ShapeType, Shape as _Shape } from 'types'
|
||||||
import useShapeEvents from 'hooks/useShapeEvents'
|
import useShapeEvents from 'hooks/useShapeEvents'
|
||||||
import vec from 'utils/vec'
|
import vec from 'utils/vec'
|
||||||
import { getShapeStyle } from 'lib/shape-styles'
|
import { getShapeStyle } from 'lib/shape-styles'
|
||||||
|
@ -60,6 +60,7 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledGroup
|
<StyledGroup
|
||||||
|
id={id + '-group'}
|
||||||
ref={rGroup}
|
ref={rGroup}
|
||||||
transform={transform}
|
transform={transform}
|
||||||
device={isMobileDevice ? 'mobile' : 'desktop'}
|
device={isMobileDevice ? 'mobile' : 'desktop'}
|
||||||
|
@ -94,6 +95,7 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
|
||||||
<RealShape
|
<RealShape
|
||||||
isParent={isParent}
|
isParent={isParent}
|
||||||
id={id}
|
id={id}
|
||||||
|
shape={shape}
|
||||||
style={style}
|
style={style}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
/>
|
/>
|
||||||
|
@ -116,15 +118,17 @@ interface RealShapeProps {
|
||||||
id: string
|
id: string
|
||||||
style: Partial<React.SVGProps<SVGUseElement>>
|
style: Partial<React.SVGProps<SVGUseElement>>
|
||||||
isParent: boolean
|
isParent: boolean
|
||||||
|
shape: _Shape
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const RealShape = memo(function RealShape({
|
const RealShape = memo(function RealShape({
|
||||||
id,
|
id,
|
||||||
|
shape,
|
||||||
style,
|
style,
|
||||||
isParent,
|
isParent,
|
||||||
}: RealShapeProps) {
|
}: RealShapeProps) {
|
||||||
return <StyledShape as="use" data-shy={isParent} href={'#' + id} {...style} />
|
return <StyledShape as="use" data-shy={isParent} href={'#' + id} />
|
||||||
})
|
})
|
||||||
|
|
||||||
const StyledShape = styled('path', {
|
const StyledShape = styled('path', {
|
||||||
|
|
|
@ -192,7 +192,11 @@ export default function useKeyboardEvents() {
|
||||||
}
|
}
|
||||||
case 'c': {
|
case 'c': {
|
||||||
if (metaKey(e)) {
|
if (metaKey(e)) {
|
||||||
state.send('COPIED', getKeyboardEventInfo(e))
|
if (e.shiftKey) {
|
||||||
|
state.send('COPIED_TO_SVG', getKeyboardEventInfo(e))
|
||||||
|
} else {
|
||||||
|
state.send('COPIED', getKeyboardEventInfo(e))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.send('SELECTED_ELLIPSE_TOOL', getKeyboardEventInfo(e))
|
state.send('SELECTED_ELLIPSE_TOOL', getKeyboardEventInfo(e))
|
||||||
}
|
}
|
||||||
|
|
16
pages/shhh.tsx
Normal file
16
pages/shhh.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// import Editor from "components/editor"
|
||||||
|
import Head from 'next/head'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
const Editor = dynamic(() => import('components/editor'), { ssr: false })
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>tldraw</title>
|
||||||
|
</Head>
|
||||||
|
<Editor />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { getShapeUtils } from 'lib/shape-utils'
|
||||||
import { Data, Shape } from 'types'
|
import { Data, Shape } from 'types'
|
||||||
|
import { getCommonBounds, getSelectedIds, getSelectedShapes } from 'utils/utils'
|
||||||
import state from './state'
|
import state from './state'
|
||||||
|
|
||||||
class Clipboard {
|
class Clipboard {
|
||||||
|
@ -43,6 +45,94 @@ class Clipboard {
|
||||||
clear = () => {
|
clear = () => {
|
||||||
this.current = undefined
|
this.current = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copySelectionToSvg(data: Data) {
|
||||||
|
const shapes = getSelectedShapes(data)
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
|
||||||
|
shapes
|
||||||
|
.sort((a, b) => a.childIndex - b.childIndex)
|
||||||
|
.forEach((shape) => {
|
||||||
|
const group = document.getElementById(shape.id + '-group')
|
||||||
|
const node = document.getElementById(shape.id)
|
||||||
|
|
||||||
|
const groupClone = group.cloneNode()
|
||||||
|
groupClone.appendChild(node.cloneNode(true))
|
||||||
|
|
||||||
|
svg.appendChild(groupClone)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bounds = getCommonBounds(
|
||||||
|
...shapes.map((shape) => getShapeUtils(shape).getBounds(shape))
|
||||||
|
)
|
||||||
|
|
||||||
|
// No content
|
||||||
|
if (!bounds) return
|
||||||
|
|
||||||
|
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(' ')
|
||||||
|
)
|
||||||
|
|
||||||
|
svg.setAttribute('width', String(bounds.width))
|
||||||
|
svg.setAttribute('height', String(bounds.height))
|
||||||
|
|
||||||
|
// Take a snapshot of the element
|
||||||
|
const s = new XMLSerializer()
|
||||||
|
const svgString = s.serializeToString(svg)
|
||||||
|
|
||||||
|
// Copy to clipboard!
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(svgString)
|
||||||
|
} catch (e) {
|
||||||
|
Clipboard.copyStringToClipboard(svgString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static copyStringToClipboard(string: string) {
|
||||||
|
let textarea: HTMLTextAreaElement
|
||||||
|
let result: boolean | null
|
||||||
|
|
||||||
|
textarea = document.createElement('textarea')
|
||||||
|
textarea.setAttribute('position', 'fixed')
|
||||||
|
textarea.setAttribute('top', '0')
|
||||||
|
textarea.setAttribute('readonly', 'true')
|
||||||
|
textarea.setAttribute('contenteditable', 'true')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.value = string
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.focus()
|
||||||
|
textarea.select()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(textarea)
|
||||||
|
|
||||||
|
const sel = window.getSelection()
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length)
|
||||||
|
result = document.execCommand('copy')
|
||||||
|
} catch (err) {
|
||||||
|
result = null
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Clipboard()
|
export default new Clipboard()
|
||||||
|
|
|
@ -226,6 +226,7 @@ const state = createState({
|
||||||
ZOOMED_IN: 'zoomIn',
|
ZOOMED_IN: 'zoomIn',
|
||||||
ZOOMED_OUT: 'zoomOut',
|
ZOOMED_OUT: 'zoomOut',
|
||||||
RESET_CAMERA: 'resetCamera',
|
RESET_CAMERA: 'resetCamera',
|
||||||
|
COPIED_TO_SVG: 'copyToSvg',
|
||||||
},
|
},
|
||||||
initial: 'notPointing',
|
initial: 'notPointing',
|
||||||
states: {
|
states: {
|
||||||
|
@ -1640,6 +1641,10 @@ const state = createState({
|
||||||
|
|
||||||
/* ---------------------- Data ---------------------- */
|
/* ---------------------- Data ---------------------- */
|
||||||
|
|
||||||
|
copyToSvg(data) {
|
||||||
|
clipboard.copySelectionToSvg(data)
|
||||||
|
},
|
||||||
|
|
||||||
copyToClipboard(data) {
|
copyToClipboard(data) {
|
||||||
clipboard.copy(getSelectedShapes(data))
|
clipboard.copy(getSelectedShapes(data))
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue