Add fit to content for frames. (#2275)

Adds Fit to content option for frames. This resizes the frames so that
the whole content fits. It also adds 50px padding on all sides so that
the content does not touch the frame's borders.



https://github.com/tldraw/tldraw/assets/2523721/b2f86e31-7dfb-495f-ac31-f1e0125e0af1



https://github.com/tldraw/tldraw/assets/2523721/e0a73d25-ac9f-4a35-a1fd-4aed7a5b151c



Fixes #1407

### Change Type

- [ ] `patch` — Bug fix
- [x] `minor` — New feature
- [ ] `major` — Breaking change
- [ ] `dependencies` — Changes to package dependencies[^1]
- [ ] `documentation` — Changes to the documentation only[^2]
- [ ] `tests` — Changes to any test code only[^2]
- [ ] `internal` — Any other changes that don't affect the published
package[^2]
- [ ] I don't know

[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version

### Test Plan

1. Add some shapes.
2. Add a frame that encloses those shapes.
3. Right click on the frame and choose `Fit to content`
4. The frame should resize to fit all the children with some padding on
all sides of the frame.

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

### Release Notes

- Add Fit to content option to the context menu for frames. This resizes
the frames to correctly fit all their content.

---------

Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mitja Bezenšek 2023-12-07 13:57:56 +01:00 committed by GitHub
parent 0cf6a1e464
commit 300466f52a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 384 additions and 89 deletions

View file

@ -36,6 +36,7 @@
"action.export-as-png": "Export as PNG",
"action.export-as-svg.short": "SVG",
"action.export-as-svg": "Export as SVG",
"action.fit-frame-to-content": "Fit to content",
"action.flip-horizontal": "Flip horizontally",
"action.flip-vertical": "Flip vertically",
"action.flip-horizontal.short": "Flip H",

View file

@ -887,7 +887,6 @@ export class Editor extends EventEmitter<TLEventMap> {
registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
type: T;
} : TLExternalContent) => void) | null): this;
removeFrame(ids: TLShapeId[]): this;
renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this;
// @deprecated (undocumented)
get renderingBounds(): Box2d;

View file

@ -16390,59 +16390,6 @@
"isAbstract": false,
"name": "registerExternalContentHandler"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#removeFrame:member(1)",
"docComment": "/**\n * Remove a frame.\n *\n * @param ids - Ids of the frames you wish to remove.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "removeFrame(ids: "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "this"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "ids",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "removeFrame"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#renamePage:member(1)",

View file

@ -7317,36 +7317,6 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
/**
* Remove a frame.
*
* @param ids - Ids of the frames you wish to remove.
*
* @public
*/
removeFrame(ids: TLShapeId[]): this {
const frames = compact(
ids
.map((id) => this.getShape<TLFrameShape>(id))
.filter((f) => f && this.isShapeOfType<TLFrameShape>(f, 'frame'))
)
if (!frames.length) return this
const allChildren: TLShapeId[] = []
this.batch(() => {
frames.map((frame) => {
const children = this.getSortedChildIdsForParent(frame.id)
if (children.length) {
this.reparentShapes(children, frame.parentId, frame.index)
allChildren.push(...children)
}
})
this.setSelectedShapes(allChildren)
this.deleteShapes(ids)
})
return this
}
/**
* Update a shape using a partial of the shape.
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -154,6 +154,7 @@ export { getEmbedInfo } from './lib/utils/embeds/embeds'
export { copyAs } from './lib/utils/export/copyAs'
export { getSvgAsImage } from './lib/utils/export/export'
export { exportAs } from './lib/utils/export/exportAs'
export { fitFrameToContent, removeFrame } from './lib/utils/frames/frames'
export { setDefaultEditorAssetUrls } from './lib/utils/static-assets/assetUrls'
export { truncateStringWithEllipsis } from './lib/utils/text/text'
export {

View file

@ -19,6 +19,7 @@ import {
} from '@tldraw/editor'
import * as React from 'react'
import { getEmbedInfo } from '../../utils/embeds/embeds'
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
import { EditLinkDialog } from '../components/EditLinkDialog'
import { EmbedDialog } from '../components/EmbedDialog'
import { TLUiIconType } from '../icon-types'
@ -471,7 +472,25 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
selectedShapes.every((shape) => editor.isShapeOfType<TLFrameShape>(shape, 'frame'))
) {
editor.mark('remove-frame')
editor.removeFrame(selectedShapes.map((shape) => shape.id))
removeFrame(
editor,
selectedShapes.map((shape) => shape.id)
)
}
},
},
{
id: 'fit-frame-to-content',
label: 'action.fit-frame-to-content',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
trackEvent('fit-frame-to-content', { source })
const onlySelectedShape = editor.getOnlySelectedShape()
if (onlySelectedShape && editor.isShapeOfType<TLFrameShape>(onlySelectedShape, 'frame')) {
editor.mark('fit-frame-to-content')
fitFrameToContent(editor, onlySelectedShape.id)
}
},
},

View file

@ -81,6 +81,10 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
const allowRemoveFrame =
oneSelected &&
selectedShapes.every((shape) => editor.isShapeOfType<TLFrameShape>(shape, 'frame'))
const allowFitFrameToContent =
onlySelectedShape &&
editor.isShapeOfType<TLFrameShape>(onlySelectedShape, 'frame') &&
editor.getSortedChildIdsForParent(onlySelectedShape).length > 0
const isShapeLocked = onlySelectedShape && editor.isShapeOrAncestorLocked(onlySelectedShape)
const contextTLUiMenuSchema = useMemo<TLUiMenuSchema>(() => {
@ -93,6 +97,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
allowGroup && !isShapeLocked && menuItem(actions['group']),
allowUngroup && !isShapeLocked && menuItem(actions['ungroup']),
allowRemoveFrame && !isShapeLocked && menuItem(actions['remove-frame']),
allowFitFrameToContent && !isShapeLocked && menuItem(actions['fit-frame-to-content']),
oneSelected && menuItem(actions['toggle-lock'])
),
menuGroup(
@ -227,6 +232,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
allowGroup,
allowUngroup,
allowRemoveFrame,
allowFitFrameToContent,
hasClipboardWrite,
showEditLink,
// oneEmbedSelected,

View file

@ -28,6 +28,7 @@ export interface TLUiEventMap {
'group-shapes': null
'ungroup-shapes': null
'remove-frame': null
'fit-frame-to-content': null
'convert-to-embed': null
'convert-to-bookmark': null
'open-embed-link': null

View file

@ -40,6 +40,7 @@ export type TLUiTranslationKey =
| 'action.export-as-png'
| 'action.export-as-svg.short'
| 'action.export-as-svg'
| 'action.fit-frame-to-content'
| 'action.flip-horizontal'
| 'action.flip-vertical'
| 'action.flip-horizontal.short'

View file

@ -40,6 +40,7 @@ export const DEFAULT_TRANSLATION = {
'action.export-as-png': 'Export as PNG',
'action.export-as-svg.short': 'SVG',
'action.export-as-svg': 'Export as SVG',
'action.fit-frame-to-content': 'Fit to content',
'action.flip-horizontal': 'Flip horizontally',
'action.flip-vertical': 'Flip vertically',
'action.flip-horizontal.short': 'Flip H',

View file

@ -0,0 +1,101 @@
import {
Box2d,
Editor,
TLFrameShape,
TLShapeId,
TLShapePartial,
Vec2d,
compact,
} from '@tldraw/editor'
/**
* Remove a frame.
*
* @param editor - tlraw editor instance.
* @param ids - Ids of the frames you wish to remove.
*
* @public
*/
export function removeFrame(editor: Editor, ids: TLShapeId[]) {
const frames = compact(
ids
.map((id) => editor.getShape<TLFrameShape>(id))
.filter((f) => f && editor.isShapeOfType<TLFrameShape>(f, 'frame'))
)
if (!frames.length) return
const allChildren: TLShapeId[] = []
editor.batch(() => {
frames.map((frame) => {
const children = editor.getSortedChildIdsForParent(frame.id)
if (children.length) {
editor.reparentShapes(children, frame.parentId, frame.index)
allChildren.push(...children)
}
})
editor.setSelectedShapes(allChildren)
editor.deleteShapes(ids)
})
}
/** @internal */
export const DEFAULT_FRAME_PADDING = 50
/**
* Fit a frame to its content.
*
* @param id - Id of the frame you wish to fit to content.
* @param editor - tlraw editor instance.
* @param opts - Options for fitting the frame.
*
* @public
*/
export function fitFrameToContent(editor: Editor, id: TLShapeId, opts = {} as { padding: number }) {
const frame = editor.getShape<TLFrameShape>(id)
if (!frame) return
const childIds = editor.getSortedChildIdsForParent(frame.id)
const children = compact(childIds.map((id) => editor.getShape(id)))
if (!children.length) return
const bounds = Box2d.FromPoints(
children.flatMap((shape) => {
const geometry = editor.getShapeGeometry(shape.id)
return editor.getShapeLocalTransform(shape)!.applyToPoints(geometry.vertices)
})
)
const { padding = DEFAULT_FRAME_PADDING } = opts
const w = bounds.w + 2 * padding
const h = bounds.h + 2 * padding
const dx = padding - bounds.minX
const dy = padding - bounds.minY
// The shapes already perfectly fit the frame.
if (dx === 0 && dy === 0 && frame.props.w === w && frame.props.h === h) return
const diff = new Vec2d(dx, dy).rot(frame.rotation)
editor.batch(() => {
const changes: TLShapePartial[] = childIds.map((child) => {
const shape = editor.getShape(child)!
return {
id: shape.id,
type: shape.type,
x: shape.x + dx,
y: shape.y + dy,
}
})
changes.push({
id: frame.id,
type: frame.type,
x: frame.x - diff.x,
y: frame.y - diff.y,
props: {
w,
h,
},
})
editor.updateShapes(changes)
})
}

View file

@ -5,6 +5,7 @@ import {
TLShapeId,
createShapeId,
} from '@tldraw/editor'
import { DEFAULT_FRAME_PADDING, fitFrameToContent, removeFrame } from '../lib/utils/frames/frames'
import { TestEditor } from './TestEditor'
let editor: TestEditor
@ -713,6 +714,70 @@ describe('frame shapes', () => {
arrow = editor.getOnlySelectedShape()! as TLArrowShape
expect(arrow.props.end).toMatchObject({ boundShapeId: innerBoxId })
})
it('correctly fits to its content', () => {
// Create two rects, their bounds are from [100, 100] to [400, 400],
// so the frame that fits them (with 50px offset) should be from [50, 50] to [450, 450].
const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
const rectBId = createRect({ pos: [300, 300], size: [100, 100] })
// Create the frame that encloses both rects
const frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
const frame = editor.getShape(frameId)! as TLFrameShape
const rectA = editor.getShape(rectAId)!
const rectB = editor.getShape(rectBId)!
expect(rectA.parentId).toBe(frameId)
expect(rectB.parentId).toBe(frameId)
fitFrameToContent(editor, frame.id)
const newFrame = editor.getShape(frameId)! as TLFrameShape
expect(newFrame.x).toBe(50)
expect(newFrame.y).toBe(50)
expect(newFrame.props.w).toBe(400)
expect(newFrame.props.h).toBe(400)
const newRectA = editor.getShape(rectAId)!
const newRectB = editor.getShape(rectBId)!
// Rect positions should change by 50px since the frame moved
// This keeps them in the same relative position
expect(newRectA.x).toBe(DEFAULT_FRAME_PADDING)
expect(newRectA.y).toBe(DEFAULT_FRAME_PADDING)
expect(newRectB.x).toBe(250)
expect(newRectB.y).toBe(250)
})
it('uses padding option', () => {
// Create two rects, their bounds are from [100, 100] to [400, 400],
// so the frame that fits them (with 50px offset) should be from [50, 50] to [450, 450].
const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
const rectBId = createRect({ pos: [300, 300], size: [100, 100] })
// Create the frame that encloses both rects
const frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
const frame = editor.getShape(frameId)! as TLFrameShape
const rectA = editor.getShape(rectAId)!
const rectB = editor.getShape(rectBId)!
expect(rectA.parentId).toBe(frameId)
expect(rectB.parentId).toBe(frameId)
fitFrameToContent(editor, frame.id, { padding: 100 })
const newFrame = editor.getShape(frameId)! as TLFrameShape
expect(newFrame.x).toBe(0)
expect(newFrame.y).toBe(0)
expect(newFrame.props.w).toBe(500)
expect(newFrame.props.h).toBe(500)
const newRectA = editor.getShape(rectAId)!
const newRectB = editor.getShape(rectBId)!
// frame is at 0,0 so positions should be the same for this test
expect(newRectA.x).toBe(100)
expect(newRectA.y).toBe(100)
expect(newRectB.x).toBe(300)
expect(newRectB.y).toBe(300)
})
})
test('arrows bound to a shape within a group within a frame are reparented if the group is moved outside of the frame', () => {
@ -863,14 +928,14 @@ describe('When deleting/removing a frame', () => {
it('removes a frame but not its children', () => {
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
const frameId = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
editor.removeFrame([frameId])
removeFrame(editor, [frameId])
expect(editor.getShape(rectId)).toBeDefined()
})
it('reparents the children of a frame when removing it', () => {
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
const frame1Id = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
const frame2Id = dragCreateFrame({ down: [0, 0], move: [110, 110], up: [110, 110] })
editor.removeFrame([frame1Id])
removeFrame(editor, [frame1Id])
expect(editor.getShape(rectId)?.parentId).toBe(frame2Id)
})
})