From 5bf05bbb3c8ba00c0c01009825344fc81e20a9da Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Sun, 16 Jun 2024 14:40:50 +0300 Subject: [PATCH] Flatten shapes to image(s) (#3933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/translations/main.json | 2 + packages/editor/api-report.md | 6 + packages/editor/src/lib/options.ts | 4 + packages/tldraw/api-report.md | 4 +- .../ContextMenu/DefaultContextMenuContent.tsx | 22 +- .../MainMenu/DefaultMainMenuContent.tsx | 2 + .../src/lib/ui/components/menu-items.tsx | 41 ++++ .../tldraw/src/lib/ui/context/actions.tsx | 23 ++ packages/tldraw/src/lib/ui/context/events.tsx | 1 + .../tldraw/src/lib/ui/hooks/useFlatten.ts | 202 ++++++++++++++++++ .../useTranslation/TLUiTranslationKey.ts | 2 + .../useTranslation/defaultTranslation.ts | 2 + 12 files changed, 290 insertions(+), 21 deletions(-) create mode 100644 packages/tldraw/src/lib/ui/hooks/useFlatten.ts diff --git a/assets/translations/main.json b/assets/translations/main.json index 782c24d28..ed227ce0c 100644 --- a/assets/translations/main.json +++ b/assets/translations/main.json @@ -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", diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index ec071d1ad..1420a5202 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -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 { diff --git a/packages/editor/src/lib/options.ts b/packages/editor/src/lib/options.ts index fbc4f6b47..971ba8670 100644 --- a/packages/editor/src/lib/options.ts +++ b/packages/editor/src/lib/options.ts @@ -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 diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 8b0a5d380..0962a3756 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -2221,6 +2221,8 @@ export interface TLUiEventMap { // (undocumented) 'fit-frame-to-content': null; // (undocumented) + 'flatten-to-image': null; + // (undocumented) 'flip-shapes': { operation: 'horizontal' | 'vertical'; }; @@ -2684,7 +2686,7 @@ export interface TLUiTranslation { export type TLUiTranslationContextType = TLUiTranslation; // @public (undocumented) -export type TLUiTranslationKey = 'action.align-bottom' | 'action.align-center-horizontal.short' | 'action.align-center-horizontal' | 'action.align-center-vertical.short' | 'action.align-center-vertical' | 'action.align-left' | 'action.align-right' | 'action.align-top' | 'action.back-to-content' | 'action.bring-forward' | 'action.bring-to-front' | 'action.convert-to-bookmark' | 'action.convert-to-embed' | 'action.copy-as-json.short' | 'action.copy-as-json' | 'action.copy-as-png.short' | 'action.copy-as-png' | 'action.copy-as-svg.short' | 'action.copy-as-svg' | 'action.copy' | 'action.cut' | 'action.delete' | 'action.distribute-horizontal.short' | 'action.distribute-horizontal' | 'action.distribute-vertical.short' | 'action.distribute-vertical' | 'action.duplicate' | 'action.edit-link' | 'action.exit-pen-mode' | 'action.export-all-as-json.short' | 'action.export-all-as-json' | 'action.export-all-as-png.short' | 'action.export-all-as-png' | 'action.export-all-as-svg.short' | 'action.export-all-as-svg' | 'action.export-as-json.short' | 'action.export-as-json' | 'action.export-as-png.short' | 'action.export-as-png' | 'action.export-as-svg.short' | 'action.export-as-svg' | 'action.fit-frame-to-content' | 'action.flip-horizontal.short' | 'action.flip-horizontal' | 'action.flip-vertical.short' | 'action.flip-vertical' | 'action.fork-project-on-tldraw' | 'action.fork-project' | 'action.group' | 'action.insert-embed' | 'action.insert-media' | 'action.leave-shared-project' | 'action.new-project' | 'action.new-shared-project' | 'action.open-cursor-chat' | 'action.open-embed-link' | 'action.open-file' | 'action.pack' | 'action.paste' | 'action.print' | 'action.redo' | 'action.remove-frame' | 'action.rename' | 'action.rotate-ccw' | 'action.rotate-cw' | 'action.save-copy' | 'action.select-all' | 'action.select-none' | 'action.send-backward' | 'action.send-to-back' | 'action.share-project' | 'action.stack-horizontal.short' | 'action.stack-horizontal' | 'action.stack-vertical.short' | 'action.stack-vertical' | 'action.stop-following' | 'action.stretch-horizontal.short' | 'action.stretch-horizontal' | 'action.stretch-vertical.short' | 'action.stretch-vertical' | 'action.toggle-auto-size' | 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode' | 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode' | 'action.toggle-edge-scrolling.menu' | 'action.toggle-edge-scrolling' | 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode' | 'action.toggle-grid.menu' | 'action.toggle-grid' | 'action.toggle-lock' | 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion' | 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode' | 'action.toggle-tool-lock.menu' | 'action.toggle-tool-lock' | 'action.toggle-transparent.context-menu' | 'action.toggle-transparent.menu' | 'action.toggle-transparent' | 'action.toggle-wrap-mode.menu' | 'action.toggle-wrap-mode' | 'action.undo' | 'action.ungroup' | 'action.unlock-all' | 'action.zoom-in' | 'action.zoom-out' | 'action.zoom-to-100' | 'action.zoom-to-fit' | 'action.zoom-to-selection' | 'actions-menu.title' | 'align-style.end' | 'align-style.justify' | 'align-style.middle' | 'align-style.start' | 'arrowheadEnd-style.arrow' | 'arrowheadEnd-style.bar' | 'arrowheadEnd-style.diamond' | 'arrowheadEnd-style.dot' | 'arrowheadEnd-style.inverted' | 'arrowheadEnd-style.none' | 'arrowheadEnd-style.pipe' | 'arrowheadEnd-style.square' | 'arrowheadEnd-style.triangle' | 'arrowheadStart-style.arrow' | 'arrowheadStart-style.bar' | 'arrowheadStart-style.diamond' | 'arrowheadStart-style.dot' | 'arrowheadStart-style.inverted' | 'arrowheadStart-style.none' | 'arrowheadStart-style.pipe' | 'arrowheadStart-style.square' | 'arrowheadStart-style.triangle' | 'assets.files.upload-failed' | 'assets.url.failed' | 'color-style.black' | 'color-style.blue' | 'color-style.green' | 'color-style.grey' | 'color-style.light-blue' | 'color-style.light-green' | 'color-style.light-red' | 'color-style.light-violet' | 'color-style.orange' | 'color-style.red' | 'color-style.violet' | 'color-style.white' | 'color-style.yellow' | 'context-menu.arrange' | 'context-menu.copy-as' | 'context-menu.export-all-as' | 'context-menu.export-as' | 'context-menu.move-to-page' | 'context-menu.reorder' | 'context.pages.new-page' | 'cursor-chat.type-to-chat' | 'dash-style.dashed' | 'dash-style.dotted' | 'dash-style.draw' | 'dash-style.solid' | 'debug-panel.more' | 'document.default-name' | 'edit-link-dialog.cancel' | 'edit-link-dialog.clear' | 'edit-link-dialog.detail' | 'edit-link-dialog.invalid-url' | 'edit-link-dialog.save' | 'edit-link-dialog.title' | 'edit-link-dialog.url' | 'edit-pages-dialog.move-down' | 'edit-pages-dialog.move-up' | 'embed-dialog.back' | 'embed-dialog.cancel' | 'embed-dialog.create' | 'embed-dialog.instruction' | 'embed-dialog.invalid-url' | 'embed-dialog.title' | 'embed-dialog.url' | 'file-system.confirm-clear.cancel' | 'file-system.confirm-clear.continue' | 'file-system.confirm-clear.description' | 'file-system.confirm-clear.dont-show-again' | 'file-system.confirm-clear.title' | 'file-system.confirm-open.cancel' | 'file-system.confirm-open.description' | 'file-system.confirm-open.dont-show-again' | 'file-system.confirm-open.open' | 'file-system.confirm-open.title' | 'file-system.file-open-error.file-format-version-too-new' | 'file-system.file-open-error.generic-corrupted-file' | 'file-system.file-open-error.not-a-tldraw-file' | 'file-system.file-open-error.title' | 'file-system.shared-document-file-open-error.description' | 'file-system.shared-document-file-open-error.title' | 'fill-style.none' | 'fill-style.pattern' | 'fill-style.semi' | 'fill-style.solid' | 'focus-mode.toggle-focus-mode' | 'font-style.draw' | 'font-style.mono' | 'font-style.sans' | 'font-style.serif' | 'geo-style.arrow-down' | 'geo-style.arrow-left' | 'geo-style.arrow-right' | 'geo-style.arrow-up' | 'geo-style.check-box' | 'geo-style.cloud' | 'geo-style.diamond' | 'geo-style.ellipse' | 'geo-style.hexagon' | 'geo-style.octagon' | 'geo-style.oval' | 'geo-style.pentagon' | 'geo-style.rectangle' | 'geo-style.rhombus-2' | 'geo-style.rhombus' | 'geo-style.star' | 'geo-style.trapezoid' | 'geo-style.triangle' | 'geo-style.x-box' | 'help-menu.about' | 'help-menu.discord' | 'help-menu.github' | 'help-menu.keyboard-shortcuts' | 'help-menu.title' | 'help-menu.twitter' | 'home-project-dialog.description' | 'home-project-dialog.ok' | 'home-project-dialog.title' | 'menu.copy-as' | 'menu.edit' | 'menu.export-as' | 'menu.file' | 'menu.language' | 'menu.preferences' | 'menu.title' | 'menu.view' | 'navigation-zone.toggle-minimap' | 'navigation-zone.zoom' | 'opacity-style.0.1' | 'opacity-style.0.25' | 'opacity-style.0.5' | 'opacity-style.0.75' | 'opacity-style.1' | 'page-menu.create-new-page' | 'page-menu.edit-done' | 'page-menu.edit-start' | 'page-menu.go-to-page' | 'page-menu.max-page-count-reached' | 'page-menu.new-page-initial-name' | 'page-menu.submenu.delete' | 'page-menu.submenu.duplicate-page' | 'page-menu.submenu.move-down' | 'page-menu.submenu.move-up' | 'page-menu.submenu.rename' | 'page-menu.submenu.title' | 'page-menu.title' | 'people-menu.change-color' | 'people-menu.change-name' | 'people-menu.follow' | 'people-menu.following' | 'people-menu.invite' | 'people-menu.leading' | 'people-menu.title' | 'people-menu.user' | 'rename-project-dialog.cancel' | 'rename-project-dialog.rename' | 'rename-project-dialog.title' | 'share-menu.copied' | 'share-menu.copy-link-note' | 'share-menu.copy-link' | 'share-menu.copy-readonly-link-note' | 'share-menu.copy-readonly-link' | 'share-menu.create-snapshot-link' | 'share-menu.creating-project' | 'share-menu.default-project-name' | 'share-menu.fork-note' | 'share-menu.offline-note' | 'share-menu.project-too-large' | 'share-menu.readonly-link' | 'share-menu.save-note' | 'share-menu.share-project' | 'share-menu.snapshot-link-note' | 'share-menu.title' | 'share-menu.upload-failed' | 'sharing.confirm-leave.cancel' | 'sharing.confirm-leave.description' | 'sharing.confirm-leave.dont-show-again' | 'sharing.confirm-leave.leave' | 'sharing.confirm-leave.title' | 'shortcuts-dialog.collaboration' | 'shortcuts-dialog.edit' | 'shortcuts-dialog.file' | 'shortcuts-dialog.preferences' | 'shortcuts-dialog.title' | 'shortcuts-dialog.tools' | 'shortcuts-dialog.transform' | 'shortcuts-dialog.view' | 'size-style.l' | 'size-style.m' | 'size-style.s' | 'size-style.xl' | 'spline-style.cubic' | 'spline-style.line' | 'status.offline' | 'status.online' | 'style-panel.align' | 'style-panel.arrowhead-end' | 'style-panel.arrowhead-start' | 'style-panel.arrowheads' | 'style-panel.color' | 'style-panel.dash' | 'style-panel.fill' | 'style-panel.font' | 'style-panel.geo' | 'style-panel.mixed' | 'style-panel.opacity' | 'style-panel.position' | 'style-panel.size' | 'style-panel.spline' | 'style-panel.title' | 'style-panel.vertical-align' | 'toast.close' | 'toast.error.copy-fail.desc' | 'toast.error.copy-fail.title' | 'toast.error.export-fail.desc' | 'toast.error.export-fail.title' | 'tool-panel.drawing' | 'tool-panel.more' | 'tool-panel.shapes' | 'tool.arrow-down' | 'tool.arrow-left' | 'tool.arrow-right' | 'tool.arrow-up' | 'tool.arrow' | 'tool.asset' | 'tool.check-box' | 'tool.cloud' | 'tool.diamond' | 'tool.draw' | 'tool.ellipse' | 'tool.embed' | 'tool.eraser' | 'tool.frame' | 'tool.hand' | 'tool.hexagon' | 'tool.highlight' | 'tool.laser' | 'tool.line' | 'tool.note' | 'tool.octagon' | 'tool.oval' | 'tool.pentagon' | 'tool.rectangle' | 'tool.rhombus' | 'tool.select' | 'tool.star' | 'tool.text' | 'tool.trapezoid' | 'tool.triangle' | 'tool.x-box' | 'verticalAlign-style.end' | 'verticalAlign-style.middle' | 'verticalAlign-style.start' | 'vscode.file-open.backup-failed' | 'vscode.file-open.backup-saved' | 'vscode.file-open.backup' | 'vscode.file-open.desc' | 'vscode.file-open.dont-show-again' | 'vscode.file-open.open'; +export type TLUiTranslationKey = 'action.align-bottom' | 'action.align-center-horizontal.short' | 'action.align-center-horizontal' | 'action.align-center-vertical.short' | 'action.align-center-vertical' | 'action.align-left' | 'action.align-right' | 'action.align-top' | 'action.back-to-content' | 'action.bring-forward' | 'action.bring-to-front' | 'action.convert-to-bookmark' | 'action.convert-to-embed' | 'action.copy-as-json.short' | 'action.copy-as-json' | 'action.copy-as-png.short' | 'action.copy-as-png' | 'action.copy-as-svg.short' | 'action.copy-as-svg' | 'action.copy' | 'action.cut' | 'action.delete' | 'action.distribute-horizontal.short' | 'action.distribute-horizontal' | 'action.distribute-vertical.short' | 'action.distribute-vertical' | 'action.duplicate' | 'action.edit-link' | 'action.exit-pen-mode' | 'action.export-all-as-json.short' | 'action.export-all-as-json' | 'action.export-all-as-png.short' | 'action.export-all-as-png' | 'action.export-all-as-svg.short' | 'action.export-all-as-svg' | 'action.export-as-json.short' | 'action.export-as-json' | 'action.export-as-png.short' | 'action.export-as-png' | 'action.export-as-svg.short' | 'action.export-as-svg' | 'action.fit-frame-to-content' | 'action.flatten-to-image' | 'action.flip-horizontal.short' | 'action.flip-horizontal' | 'action.flip-vertical.short' | 'action.flip-vertical' | 'action.fork-project-on-tldraw' | 'action.fork-project' | 'action.group' | 'action.insert-embed' | 'action.insert-media' | 'action.leave-shared-project' | 'action.new-project' | 'action.new-shared-project' | 'action.open-cursor-chat' | 'action.open-embed-link' | 'action.open-file' | 'action.pack' | 'action.paste' | 'action.print' | 'action.redo' | 'action.remove-frame' | 'action.rename' | 'action.rotate-ccw' | 'action.rotate-cw' | 'action.save-copy' | 'action.select-all' | 'action.select-none' | 'action.send-backward' | 'action.send-to-back' | 'action.share-project' | 'action.stack-horizontal.short' | 'action.stack-horizontal' | 'action.stack-vertical.short' | 'action.stack-vertical' | 'action.stop-following' | 'action.stretch-horizontal.short' | 'action.stretch-horizontal' | 'action.stretch-vertical.short' | 'action.stretch-vertical' | 'action.toggle-auto-size' | 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode' | 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode' | 'action.toggle-edge-scrolling.menu' | 'action.toggle-edge-scrolling' | 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode' | 'action.toggle-grid.menu' | 'action.toggle-grid' | 'action.toggle-lock' | 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion' | 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode' | 'action.toggle-tool-lock.menu' | 'action.toggle-tool-lock' | 'action.toggle-transparent.context-menu' | 'action.toggle-transparent.menu' | 'action.toggle-transparent' | 'action.toggle-wrap-mode.menu' | 'action.toggle-wrap-mode' | 'action.undo' | 'action.ungroup' | 'action.unlock-all' | 'action.zoom-in' | 'action.zoom-out' | 'action.zoom-to-100' | 'action.zoom-to-fit' | 'action.zoom-to-selection' | 'actions-menu.title' | 'align-style.end' | 'align-style.justify' | 'align-style.middle' | 'align-style.start' | 'arrowheadEnd-style.arrow' | 'arrowheadEnd-style.bar' | 'arrowheadEnd-style.diamond' | 'arrowheadEnd-style.dot' | 'arrowheadEnd-style.inverted' | 'arrowheadEnd-style.none' | 'arrowheadEnd-style.pipe' | 'arrowheadEnd-style.square' | 'arrowheadEnd-style.triangle' | 'arrowheadStart-style.arrow' | 'arrowheadStart-style.bar' | 'arrowheadStart-style.diamond' | 'arrowheadStart-style.dot' | 'arrowheadStart-style.inverted' | 'arrowheadStart-style.none' | 'arrowheadStart-style.pipe' | 'arrowheadStart-style.square' | 'arrowheadStart-style.triangle' | 'assets.files.upload-failed' | 'assets.url.failed' | 'color-style.black' | 'color-style.blue' | 'color-style.green' | 'color-style.grey' | 'color-style.light-blue' | 'color-style.light-green' | 'color-style.light-red' | 'color-style.light-violet' | 'color-style.orange' | 'color-style.red' | 'color-style.violet' | 'color-style.white' | 'color-style.yellow' | 'context-menu.arrange' | 'context-menu.copy-as' | 'context-menu.edit' | 'context-menu.export-all-as' | 'context-menu.export-as' | 'context-menu.move-to-page' | 'context-menu.reorder' | 'context.pages.new-page' | 'cursor-chat.type-to-chat' | 'dash-style.dashed' | 'dash-style.dotted' | 'dash-style.draw' | 'dash-style.solid' | 'debug-panel.more' | 'document.default-name' | 'edit-link-dialog.cancel' | 'edit-link-dialog.clear' | 'edit-link-dialog.detail' | 'edit-link-dialog.invalid-url' | 'edit-link-dialog.save' | 'edit-link-dialog.title' | 'edit-link-dialog.url' | 'edit-pages-dialog.move-down' | 'edit-pages-dialog.move-up' | 'embed-dialog.back' | 'embed-dialog.cancel' | 'embed-dialog.create' | 'embed-dialog.instruction' | 'embed-dialog.invalid-url' | 'embed-dialog.title' | 'embed-dialog.url' | 'file-system.confirm-clear.cancel' | 'file-system.confirm-clear.continue' | 'file-system.confirm-clear.description' | 'file-system.confirm-clear.dont-show-again' | 'file-system.confirm-clear.title' | 'file-system.confirm-open.cancel' | 'file-system.confirm-open.description' | 'file-system.confirm-open.dont-show-again' | 'file-system.confirm-open.open' | 'file-system.confirm-open.title' | 'file-system.file-open-error.file-format-version-too-new' | 'file-system.file-open-error.generic-corrupted-file' | 'file-system.file-open-error.not-a-tldraw-file' | 'file-system.file-open-error.title' | 'file-system.shared-document-file-open-error.description' | 'file-system.shared-document-file-open-error.title' | 'fill-style.none' | 'fill-style.pattern' | 'fill-style.semi' | 'fill-style.solid' | 'focus-mode.toggle-focus-mode' | 'font-style.draw' | 'font-style.mono' | 'font-style.sans' | 'font-style.serif' | 'geo-style.arrow-down' | 'geo-style.arrow-left' | 'geo-style.arrow-right' | 'geo-style.arrow-up' | 'geo-style.check-box' | 'geo-style.cloud' | 'geo-style.diamond' | 'geo-style.ellipse' | 'geo-style.hexagon' | 'geo-style.octagon' | 'geo-style.oval' | 'geo-style.pentagon' | 'geo-style.rectangle' | 'geo-style.rhombus-2' | 'geo-style.rhombus' | 'geo-style.star' | 'geo-style.trapezoid' | 'geo-style.triangle' | 'geo-style.x-box' | 'help-menu.about' | 'help-menu.discord' | 'help-menu.github' | 'help-menu.keyboard-shortcuts' | 'help-menu.title' | 'help-menu.twitter' | 'home-project-dialog.description' | 'home-project-dialog.ok' | 'home-project-dialog.title' | 'menu.copy-as' | 'menu.edit' | 'menu.export-as' | 'menu.file' | 'menu.language' | 'menu.preferences' | 'menu.title' | 'menu.view' | 'navigation-zone.toggle-minimap' | 'navigation-zone.zoom' | 'opacity-style.0.1' | 'opacity-style.0.25' | 'opacity-style.0.5' | 'opacity-style.0.75' | 'opacity-style.1' | 'page-menu.create-new-page' | 'page-menu.edit-done' | 'page-menu.edit-start' | 'page-menu.go-to-page' | 'page-menu.max-page-count-reached' | 'page-menu.new-page-initial-name' | 'page-menu.submenu.delete' | 'page-menu.submenu.duplicate-page' | 'page-menu.submenu.move-down' | 'page-menu.submenu.move-up' | 'page-menu.submenu.rename' | 'page-menu.submenu.title' | 'page-menu.title' | 'people-menu.change-color' | 'people-menu.change-name' | 'people-menu.follow' | 'people-menu.following' | 'people-menu.invite' | 'people-menu.leading' | 'people-menu.title' | 'people-menu.user' | 'rename-project-dialog.cancel' | 'rename-project-dialog.rename' | 'rename-project-dialog.title' | 'share-menu.copied' | 'share-menu.copy-link-note' | 'share-menu.copy-link' | 'share-menu.copy-readonly-link-note' | 'share-menu.copy-readonly-link' | 'share-menu.create-snapshot-link' | 'share-menu.creating-project' | 'share-menu.default-project-name' | 'share-menu.fork-note' | 'share-menu.offline-note' | 'share-menu.project-too-large' | 'share-menu.readonly-link' | 'share-menu.save-note' | 'share-menu.share-project' | 'share-menu.snapshot-link-note' | 'share-menu.title' | 'share-menu.upload-failed' | 'sharing.confirm-leave.cancel' | 'sharing.confirm-leave.description' | 'sharing.confirm-leave.dont-show-again' | 'sharing.confirm-leave.leave' | 'sharing.confirm-leave.title' | 'shortcuts-dialog.collaboration' | 'shortcuts-dialog.edit' | 'shortcuts-dialog.file' | 'shortcuts-dialog.preferences' | 'shortcuts-dialog.title' | 'shortcuts-dialog.tools' | 'shortcuts-dialog.transform' | 'shortcuts-dialog.view' | 'size-style.l' | 'size-style.m' | 'size-style.s' | 'size-style.xl' | 'spline-style.cubic' | 'spline-style.line' | 'status.offline' | 'status.online' | 'style-panel.align' | 'style-panel.arrowhead-end' | 'style-panel.arrowhead-start' | 'style-panel.arrowheads' | 'style-panel.color' | 'style-panel.dash' | 'style-panel.fill' | 'style-panel.font' | 'style-panel.geo' | 'style-panel.mixed' | 'style-panel.opacity' | 'style-panel.position' | 'style-panel.size' | 'style-panel.spline' | 'style-panel.title' | 'style-panel.vertical-align' | 'toast.close' | 'toast.error.copy-fail.desc' | 'toast.error.copy-fail.title' | 'toast.error.export-fail.desc' | 'toast.error.export-fail.title' | 'tool-panel.drawing' | 'tool-panel.more' | 'tool-panel.shapes' | 'tool.arrow-down' | 'tool.arrow-left' | 'tool.arrow-right' | 'tool.arrow-up' | 'tool.arrow' | 'tool.asset' | 'tool.check-box' | 'tool.cloud' | 'tool.diamond' | 'tool.draw' | 'tool.ellipse' | 'tool.embed' | 'tool.eraser' | 'tool.frame' | 'tool.hand' | 'tool.hexagon' | 'tool.highlight' | 'tool.laser' | 'tool.line' | 'tool.note' | 'tool.octagon' | 'tool.oval' | 'tool.pentagon' | 'tool.rectangle' | 'tool.rhombus' | 'tool.select' | 'tool.star' | 'tool.text' | 'tool.trapezoid' | 'tool.triangle' | 'tool.x-box' | 'verticalAlign-style.end' | 'verticalAlign-style.middle' | 'verticalAlign-style.start' | 'vscode.file-open.backup-failed' | 'vscode.file-open.backup-saved' | 'vscode.file-open.backup' | 'vscode.file-open.desc' | 'vscode.file-open.dont-show-again' | 'vscode.file-open.open'; // @public (undocumented) export interface TLUiTranslationProviderProps { diff --git a/packages/tldraw/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx b/packages/tldraw/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx index 6dc90182c..65c982a5d 100644 --- a/packages/tldraw/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx +++ b/packages/tldraw/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx @@ -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 ( <> - - - - - - - - - - - + diff --git a/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx b/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx index 1f9005c97..2a235cbf7 100644 --- a/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx +++ b/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx @@ -9,6 +9,7 @@ import { ConvertToEmbedMenuItem, EditLinkMenuItem, FitFrameToContentMenuItem, + FlattenMenuItem, GroupMenuItem, RemoveFrameMenuItem, SelectAllMenuItem, @@ -101,6 +102,7 @@ export function MiscMenuGroup() { + ) } diff --git a/packages/tldraw/src/lib/ui/components/menu-items.tsx b/packages/tldraw/src/lib/ui/components/menu-items.tsx index e4680768e..21b2fdc3d 100644 --- a/packages/tldraw/src/lib/ui/components/menu-items.tsx +++ b/packages/tldraw/src/lib/ui/components/menu-items.tsx @@ -2,6 +2,7 @@ import { TLBookmarkShape, TLEmbedShape, TLFrameShape, + TLImageShape, TLPageId, useEditor, useValue, @@ -51,6 +52,27 @@ export function DuplicateMenuItem() { return } /** @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(onlySelectedShape, 'image')) { + return false + } + return true + }, + [editor] + ) + if (!shouldDisplay) return null + + return +} +/** @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 ( + + + + + + + + + + + + + ) +} + /** @public @react */ export function ArrangeMenuSubmenu() { const twoSelected = useUnlockedSelectedShapesCount(2) diff --git a/packages/tldraw/src/lib/ui/context/actions.tsx b/packages/tldraw/src/lib/ui/context/actions.tsx index 635fdb96d..ac8759ea5 100644 --- a/packages/tldraw/src/lib/ui/context/actions.tsx +++ b/packages/tldraw/src/lib/ui/context/actions.tsx @@ -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) diff --git a/packages/tldraw/src/lib/ui/context/events.tsx b/packages/tldraw/src/lib/ui/context/events.tsx index 7c948b020..35a427010 100644 --- a/packages/tldraw/src/lib/ui/context/events.tsx +++ b/packages/tldraw/src/lib/ui/context/events.tsx @@ -96,6 +96,7 @@ export interface TLUiEventMap { 'open-cursor-chat': null 'zoom-tool': null 'unlock-all': null + 'flatten-to-image': null } /** @public */ diff --git a/packages/tldraw/src/lib/ui/hooks/useFlatten.ts b/packages/tldraw/src/lib/ui/hooks/useFlatten.ts new file mode 100644 index 000000000..ea8ca123b --- /dev/null +++ b/packages/tldraw/src/lib/ui/hooks/useFlatten.ts @@ -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({ + 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] + ) +} diff --git a/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts b/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts index 6bcb41a72..e9cf03848 100644 --- a/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts +++ b/packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts @@ -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' diff --git a/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts b/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts index d1ac772a9..ea26d1db9 100644 --- a/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts +++ b/packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts @@ -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',