Flatten shapes to image(s) (#3933)

This PR adds some functionality for turning shapes into images.

![Kapture 2024-06-13 at 12 51
00](https://github.com/tldraw/tldraw/assets/23072548/78525e29-61b5-418f-889d-2f061f26f34d)

It adds:
- the `flattenShapesToImages`
- the `useFlatten` hook
- a `flatten-shapes-to-images` action (shift + f)
- adds `flattenImageBoundsExpand` option
- adds `flattenImageBoundsPadding` option

## Flatten shapes to images

The `flattenShapesToImages` helper method will 1) create an image for
the given shape ids, 2) add it to the canvas in the same location / size
as the source shapes, and then 3) delete the original shapes. The new
image will be placed correctly in the z index and in the correct
rotation of the root-most ancestor of the given shape ids.


![image](https://github.com/tldraw/tldraw/assets/23072548/fe888980-05a5-4369-863f-90c142f9f8b9)

It has an argument, `flattenImageBoundsExpand`, which if provided will
chunk the given shapes into images based on their overlapping (expanded)
bounding boxes.


![image](https://github.com/tldraw/tldraw/assets/23072548/c4799309-244d-4a2b-ac59-9c2fd100319c)

By default, the flatten action uses the editor's
`options.flattenImageBoundsExpand`. The `flattenImageBoundsPadding`
option is used as a value for how much larger the image should be than
the source image bounds (to account for large strokes, for example).

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `feature` — New feature

### Test Plan

1. Select shapes
2. Select context menu > edit > flatten

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add Flatten, a new menu item to flatten shapes into images
This commit is contained in:
Steve Ruiz 2024-06-16 14:40:50 +03:00 committed by GitHub
parent db2e88e5d5
commit 5bf05bbb3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 290 additions and 21 deletions

View file

@ -96,6 +96,7 @@
"action.toggle-grid.menu": "Show grid",
"action.toggle-grid": "Toggle grid",
"action.toggle-lock": "Toggle locked",
"action.flatten-to-image": "Flatten",
"action.toggle-snap-mode.menu": "Always snap",
"action.toggle-snap-mode": "Toggle always snap",
"action.toggle-tool-lock.menu": "Tool lock",
@ -232,6 +233,7 @@
"menu.language": "Language",
"menu.preferences": "Preferences",
"menu.view": "View",
"context-menu.edit": "Edit",
"context-menu.arrange": "Arrange",
"context-menu.copy-as": "Copy as",
"context-menu.export-as": "Export as",

View file

@ -681,6 +681,8 @@ export const defaultTldrawOptions: {
readonly dragDistanceSquared: 16;
readonly edgeScrollDistance: 8;
readonly edgeScrollSpeed: 20;
readonly flattenImageBoundsExpand: 64;
readonly flattenImageBoundsPadding: 16;
readonly followChaseViewportSnap: 2;
readonly gridSteps: readonly [{
readonly mid: 0.15;
@ -2571,6 +2573,10 @@ export interface TldrawOptions {
// (undocumented)
readonly edgeScrollSpeed: number;
// (undocumented)
readonly flattenImageBoundsExpand: number;
// (undocumented)
readonly flattenImageBoundsPadding: number;
// (undocumented)
readonly followChaseViewportSnap: number;
// (undocumented)
readonly gridSteps: readonly {

View file

@ -45,6 +45,8 @@ export interface TldrawOptions {
readonly longPressDurationMs: number
readonly textShadowLod: number
readonly adjacentShapeMargin: number
readonly flattenImageBoundsExpand: number
readonly flattenImageBoundsPadding: number
}
/** @public */
@ -79,4 +81,6 @@ export const defaultTldrawOptions = {
longPressDurationMs: 500,
textShadowLod: 0.35,
adjacentShapeMargin: 10,
flattenImageBoundsExpand: 64,
flattenImageBoundsPadding: 16,
} as const satisfies TldrawOptions

File diff suppressed because one or more lines are too long

View file

@ -3,18 +3,10 @@ import {
ArrangeMenuSubmenu,
ClipboardMenuGroup,
ConversionsMenuGroup,
ConvertToBookmarkMenuItem,
ConvertToEmbedMenuItem,
EditLinkMenuItem,
FitFrameToContentMenuItem,
GroupMenuItem,
EditMenuSubmenu,
MoveToPageMenu,
RemoveFrameMenuItem,
ReorderMenuSubmenu,
SelectAllMenuItem,
ToggleAutoSizeMenuItem,
ToggleLockMenuItem,
UngroupMenuItem,
} from '../menu-items'
import { TldrawUiMenuGroup } from '../primitives/menus/TldrawUiMenuGroup'
@ -32,18 +24,8 @@ export function DefaultContextMenuContent() {
return (
<>
<TldrawUiMenuGroup id="misc">
<GroupMenuItem />
<UngroupMenuItem />
<EditLinkMenuItem />
<ToggleAutoSizeMenuItem />
<RemoveFrameMenuItem />
<FitFrameToContentMenuItem />
<ConvertToEmbedMenuItem />
<ConvertToBookmarkMenuItem />
<ToggleLockMenuItem />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="modify">
<EditMenuSubmenu />
<ArrangeMenuSubmenu />
<ReorderMenuSubmenu />
<MoveToPageMenu />

View file

@ -9,6 +9,7 @@ import {
ConvertToEmbedMenuItem,
EditLinkMenuItem,
FitFrameToContentMenuItem,
FlattenMenuItem,
GroupMenuItem,
RemoveFrameMenuItem,
SelectAllMenuItem,
@ -101,6 +102,7 @@ export function MiscMenuGroup() {
<FitFrameToContentMenuItem />
<ConvertToEmbedMenuItem />
<ConvertToBookmarkMenuItem />
<FlattenMenuItem />
</TldrawUiMenuGroup>
)
}

View file

@ -2,6 +2,7 @@ import {
TLBookmarkShape,
TLEmbedShape,
TLFrameShape,
TLImageShape,
TLPageId,
useEditor,
useValue,
@ -51,6 +52,27 @@ export function DuplicateMenuItem() {
return <TldrawUiMenuItem {...actions['duplicate']} />
}
/** @public @react */
export function FlattenMenuItem() {
const actions = useActions()
const editor = useEditor()
const shouldDisplay = useValue(
'should display flatten option',
() => {
const selectedShapeIds = editor.getSelectedShapeIds()
if (selectedShapeIds.length === 0) return false
const onlySelectedShape = editor.getOnlySelectedShape()
if (onlySelectedShape && editor.isShapeOfType<TLImageShape>(onlySelectedShape, 'image')) {
return false
}
return true
},
[editor]
)
if (!shouldDisplay) return null
return <TldrawUiMenuItem {...actions['flatten-to-image']} />
}
/** @public @react */
export function GroupMenuItem() {
const actions = useActions()
const shouldDisplay = useAllowGroup()
@ -305,6 +327,25 @@ export function DeleteMenuItem() {
}
/* --------------------- Modify --------------------- */
/** @public @react */
export function EditMenuSubmenu() {
return (
<TldrawUiMenuSubmenu id="edit" label="context-menu.edit" size="small">
<GroupMenuItem />
<UngroupMenuItem />
<FlattenMenuItem />
<EditLinkMenuItem />
<FitFrameToContentMenuItem />
<RemoveFrameMenuItem />
<ConvertToEmbedMenuItem />
<ConvertToBookmarkMenuItem />
<ToggleAutoSizeMenuItem />
<ToggleLockMenuItem />
</TldrawUiMenuSubmenu>
)
}
/** @public @react */
export function ArrangeMenuSubmenu() {
const twoSelected = useUnlockedSelectedShapesCount(2)

View file

@ -27,6 +27,7 @@ import { EmbedDialog } from '../components/EmbedDialog'
import { useMenuClipboardEvents } from '../hooks/useClipboardEvents'
import { useCopyAs } from '../hooks/useCopyAs'
import { useExportAs } from '../hooks/useExportAs'
import { flattenShapesToImages } from '../hooks/useFlatten'
import { useInsertMedia } from '../hooks/useInsertMedia'
import { usePrint } from '../hooks/usePrint'
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
@ -1341,6 +1342,28 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('set-style', { source, id: style.id, value: 'white' })
},
},
{
id: 'flatten-to-image',
label: 'action.flatten-to-image',
kbd: '!f',
onSelect: async (source) => {
const ids = editor.getSelectedShapeIds()
if (ids.length === 0) return
editor.mark('flattening to image')
trackEvent('flatten-to-image', { source })
const newShapeIds = await flattenShapesToImages(
editor,
ids,
editor.options.flattenImageBoundsExpand
)
if (newShapeIds?.length) {
editor.setSelectedShapes(newShapeIds)
}
},
},
]
const actions = makeActions(actionItems)

View file

@ -96,6 +96,7 @@ export interface TLUiEventMap {
'open-cursor-chat': null
'zoom-tool': null
'unlock-all': null
'flatten-to-image': null
}
/** @public */

View file

@ -0,0 +1,202 @@
import {
AssetRecordType,
Box,
Editor,
IndexKey,
TLImageAsset,
TLImageShape,
TLShape,
TLShapeId,
Vec,
compact,
createShapeId,
isShapeId,
transact,
useEditor,
} from '@tldraw/editor'
import { useCallback } from 'react'
export async function flattenShapesToImages(
editor: Editor,
shapeIds: TLShapeId[],
flattenImageBoundsExpand?: number
) {
const shapes = compact(
shapeIds.map((id) => {
const shape = editor.getShape(id)
if (!shape) return
const util = editor.getShapeUtil(shape.type)
// skip shapes that don't have a toSvg method
if (util.toSvg === undefined) return
return shape
})
)
if (shapes.length === 0) return
// Don't flatten if it's just one image
if (shapes.length === 1) {
const shape = shapes[0]
if (!shape) return
if (editor.isShapeOfType(shape, 'image')) return
}
const groups: { shapes: TLShape[]; bounds: Box; asset?: TLImageAsset }[] = []
if (flattenImageBoundsExpand !== undefined) {
const expandedBounds = shapes.map((shape) => {
return {
shape,
bounds: editor.getShapeMaskedPageBounds(shape)!.clone().expandBy(flattenImageBoundsExpand),
}
})
for (let i = 0; i < expandedBounds.length; i++) {
const item = expandedBounds[i]
if (i === 0) {
groups[0] = {
shapes: [item.shape],
bounds: item.bounds,
}
continue
}
let didLand = false
for (const group of groups) {
if (group.bounds.includes(item.bounds)) {
group.shapes.push(item.shape)
group.bounds.expand(item.bounds)
didLand = true
break
}
}
if (!didLand) {
groups.push({
shapes: [item.shape],
bounds: item.bounds,
})
}
}
} else {
const bounds = Box.Common(shapes.map((shape) => editor.getShapeMaskedPageBounds(shape)!))
groups.push({
shapes,
bounds,
})
}
const padding = editor.options.flattenImageBoundsPadding
for (const group of groups) {
if (flattenImageBoundsExpand !== undefined) {
// shrink the bounds again, removing the expanded area
group.bounds.expandBy(-flattenImageBoundsExpand)
}
// get an image for the shapes
const svgResult = await editor.getSvgString(group.shapes, {
padding,
})
if (!svgResult?.svg) continue
// get an image asset for the image
const blob = new Blob([svgResult.svg], { type: 'image/svg+xml' })
const asset = (await editor.getAssetForExternalContent({
type: 'file',
file: new File([blob], 'asset.svg', { type: 'image/svg+xml' }),
})) as TLImageAsset
if (!asset) continue
// add it to the group
group.asset = asset
}
const createdShapeIds: TLShapeId[] = []
transact(() => {
for (const group of groups) {
const { asset, bounds, shapes } = group
if (!asset) continue
const assetId = AssetRecordType.createId()
const commonAncestorId = editor.findCommonAncestor(shapes) ?? editor.getCurrentPageId()
if (!commonAncestorId) continue
let index: IndexKey = 'a1' as IndexKey
for (const shape of shapes) {
if (shape.parentId === commonAncestorId) {
if (shape.index > index) {
index = shape.index
}
break
}
}
let x: number
let y: number
let rotation: number
if (isShapeId(commonAncestorId)) {
const commonAncestor = editor.getShape(commonAncestorId)
if (!commonAncestor) continue
// put the point in the parent's space
const point = editor.getPointInShapeSpace(commonAncestor, {
x: bounds.x,
y: bounds.y,
})
// get the parent's rotation
rotation = editor.getShapePageTransform(commonAncestorId).rotation()
// rotate the point against the parent's rotation
point.sub(new Vec(padding, padding).rot(-rotation))
x = point.x
y = point.y
} else {
// if the common ancestor is the page, then just adjust for the padding
x = bounds.x - padding
y = bounds.y - padding
rotation = 0
}
// delete the shapes
editor.deleteShapes(shapes)
// create the asset
editor.createAssets([{ ...asset, id: assetId }])
const shapeId = createShapeId()
// create an image shape in the same place as the shapes
editor.createShape<TLImageShape>({
id: shapeId,
type: 'image',
index,
parentId: commonAncestorId,
x,
y,
rotation: -rotation,
props: {
assetId,
w: bounds.w + padding * 2,
h: bounds.h + padding * 2,
},
})
createdShapeIds.push(shapeId)
}
})
return createdShapeIds
}
export function useFlatten() {
const editor = useEditor()
return useCallback(
(ids: TLShapeId[]) => {
return flattenShapesToImages(editor, ids)
},
[editor]
)
}

View file

@ -100,6 +100,7 @@ export type TLUiTranslationKey =
| 'action.toggle-grid.menu'
| 'action.toggle-grid'
| 'action.toggle-lock'
| 'action.flatten-to-image'
| 'action.toggle-snap-mode.menu'
| 'action.toggle-snap-mode'
| 'action.toggle-tool-lock.menu'
@ -236,6 +237,7 @@ export type TLUiTranslationKey =
| 'menu.language'
| 'menu.preferences'
| 'menu.view'
| 'context-menu.edit'
| 'context-menu.arrange'
| 'context-menu.copy-as'
| 'context-menu.export-as'

View file

@ -100,6 +100,7 @@ export const DEFAULT_TRANSLATION = {
'action.toggle-grid.menu': 'Show grid',
'action.toggle-grid': 'Toggle grid',
'action.toggle-lock': 'Toggle locked',
'action.flatten-to-image': 'Flatten',
'action.toggle-snap-mode.menu': 'Always snap',
'action.toggle-snap-mode': 'Toggle always snap',
'action.toggle-tool-lock.menu': 'Tool lock',
@ -236,6 +237,7 @@ export const DEFAULT_TRANSLATION = {
'menu.language': 'Language',
'menu.preferences': 'Preferences',
'menu.view': 'View',
'context-menu.edit': 'Edit',
'context-menu.arrange': 'Arrange',
'context-menu.copy-as': 'Copy as',
'context-menu.export-as': 'Export as',