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>
pull/2303/head
Mitja Bezenšek 2023-12-07 13:57:56 +01:00 zatwierdzone przez GitHub
rodzic 0cf6a1e464
commit 300466f52a
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
14 zmienionych plików z 384 dodań i 89 usunięć

Wyświetl plik

@ -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",

Wyświetl plik

@ -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;

Wyświetl plik

@ -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)",

Wyświetl plik

@ -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

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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)
}
},
},

Wyświetl plik

@ -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,

Wyświetl plik

@ -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

Wyświetl plik

@ -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'

Wyświetl plik

@ -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',

Wyświetl plik

@ -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)
})
}

Wyświetl plik

@ -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)
})
})