Allow users to set document name and use it for exporting / saving (#2685)

Adds the ability to change document names in the top center part of the
UI. This mostly brings back the functionality we already had in the
past.

This is basically a port of what @SomeHats did a while back. I changed
the dropdown options and removed some of the things (we are not dealing
with network requests directly so some of that logic did not apply any
longer). We did have autosave back then, not sure if we want to bring
that back?

Changes the `exportAs` api, thus braking.

### Change Type

- [ ] `patch` — Bug fix
- [ ] `minor` — New feature
- [x] `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. Top center should now show a new UI element. It has a dropdown with a
few actions.
2. Double clicking the name should also start editing it.
3. The name should also be respected when exporting things. Not if you
select some shapes or a frame. In that case we still use the old names.
But if you don't have anything selected and then export / save a project
it should have the document name.

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

### Release Notes

- Allow users to name their documents.
This commit is contained in:
Mitja Bezenšek 2024-02-19 13:30:26 +01:00 committed by GitHub
parent 1e7af3a3e0
commit b3d6af4454
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 473 additions and 35 deletions

View file

@ -0,0 +1,301 @@
import {
OfflineIndicator,
TLUiTranslationKey,
TldrawUiButton,
TldrawUiButtonIcon,
TldrawUiDropdownMenuContent,
TldrawUiDropdownMenuGroup,
TldrawUiDropdownMenuItem,
TldrawUiDropdownMenuRoot,
TldrawUiDropdownMenuTrigger,
TldrawUiKbd,
track,
useActions,
useBreakpoint,
useEditor,
useTranslation,
} from '@tldraw/tldraw'
import {
ChangeEvent,
KeyboardEvent,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { FORK_PROJECT_ACTION } from '../../utils/sharing'
import { SAVE_FILE_COPY_ACTION } from '../../utils/useFileSystem'
import { getShareUrl } from '../ShareMenu'
type NameState = {
readonly name: string | null
readonly isEditing: boolean
}
const MAX_TITLE_WIDTH_PX = 420
const BUTTON_WIDTH = 44
const MARGIN_BETWEEN_ZONES = 12
export const DocumentTopZone = track(function DocumentTopZone({
isOffline,
}: {
isOffline: boolean
}) {
const isDocumentNameVisible = useBreakpoint() >= 4
return (
<DocumentTopZoneContainer>
{isDocumentNameVisible && <DocumentNameInner />}
{isOffline && <OfflineIndicator />}
</DocumentTopZoneContainer>
)
})
export const DocumentNameInner = track(function DocumentNameInner() {
const [state, setState] = useState<NameState>({ name: null, isEditing: false })
const actions = useActions()
const forkAction = actions[FORK_PROJECT_ACTION]
const saveFileAction = actions[SAVE_FILE_COPY_ACTION]
const editor = useEditor()
const msg = useTranslation()
return (
<div className="tlui-document-name__inner">
<DocumentNameEditor state={state} setState={setState} />
<TldrawUiDropdownMenuRoot id="document-name">
<TldrawUiDropdownMenuTrigger>
<TldrawUiButton
type="icon"
className="tlui-document-name__menu tlui-menu__trigger flex-none"
>
<TldrawUiButtonIcon icon="chevron-down" />
</TldrawUiButton>
</TldrawUiDropdownMenuTrigger>
<TldrawUiDropdownMenuContent align="end" alignOffset={4} sideOffset={6}>
<TldrawUiDropdownMenuGroup>
<TldrawUiDropdownMenuItem>
<TldrawUiButton
type="menu"
onClick={() => setState((prev) => ({ ...prev, isEditing: true }))}
>
{' '}
<span className={'tlui-button__label' as any}>{msg('action.rename')}</span>
</TldrawUiButton>
</TldrawUiDropdownMenuItem>
<TldrawUiDropdownMenuItem>
<TldrawUiButton
type="menu"
onClick={() => {
const shareLink = getShareUrl(
window.location.href,
editor.getInstanceState().isReadonly
)
navigator.clipboard.writeText(shareLink)
}}
>
<span className={'tlui-button__label' as any}>Copy link</span>
</TldrawUiButton>
</TldrawUiDropdownMenuItem>
<TldrawUiDropdownMenuItem>
<TldrawUiButton type="menu" onClick={() => saveFileAction.onSelect('document-name')}>
<span className={'tlui-button__label' as any}>
{msg(saveFileAction.label! as TLUiTranslationKey)}
</span>
{saveFileAction.kbd && <TldrawUiKbd>{saveFileAction.kbd}</TldrawUiKbd>}
</TldrawUiButton>
</TldrawUiDropdownMenuItem>
<TldrawUiDropdownMenuItem>
<TldrawUiButton type="menu" onClick={() => forkAction.onSelect('document-name')}>
<span className={'tlui-button__label' as any}>
{msg(forkAction.label! as TLUiTranslationKey)}
</span>
</TldrawUiButton>
</TldrawUiDropdownMenuItem>
</TldrawUiDropdownMenuGroup>
</TldrawUiDropdownMenuContent>
</TldrawUiDropdownMenuRoot>
</div>
)
})
function DocumentTopZoneContainer({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
const updateLayout = useCallback(() => {
const element = ref.current
if (!element) return
const layoutTop = element.parentElement!.parentElement!
const leftPanel = layoutTop.querySelector('.tlui-layout__top__left')! as HTMLElement
const rightPanel = layoutTop.querySelector('.tlui-layout__top__right')! as HTMLElement
const totalWidth = layoutTop.offsetWidth
const leftWidth = leftPanel.offsetWidth
const rightWidth = rightPanel.offsetWidth
// ignore the width of the button:
const selfWidth = element.offsetWidth - BUTTON_WIDTH
let xCoordIfCentered = (totalWidth - selfWidth) / 2
// Prevent subpixel bullsh
if (totalWidth % 2 !== 0) {
xCoordIfCentered -= 0.5
}
const xCoordIfLeftAligned = leftWidth + MARGIN_BETWEEN_ZONES
const left = element.offsetLeft
const xCoord = Math.max(xCoordIfCentered, xCoordIfLeftAligned) - left
const maxWidth = Math.min(
totalWidth - rightWidth - leftWidth - 2 * MARGIN_BETWEEN_ZONES,
MAX_TITLE_WIDTH_PX
)
element.style.setProperty('transform', `translate(${xCoord}px, 0px)`)
element.style.setProperty('max-width', maxWidth + 'px')
}, [])
useLayoutEffect(() => {
const element = ref.current
if (!element) return
const layoutTop = element.parentElement!.parentElement!
const leftPanel = layoutTop.querySelector('.tlui-layout__top__left')! as HTMLElement
const rightPanel = layoutTop.querySelector('.tlui-layout__top__right')! as HTMLElement
// Update layout when the things change
const observer = new ResizeObserver(updateLayout)
observer.observe(leftPanel)
observer.observe(rightPanel)
observer.observe(layoutTop)
observer.observe(element)
// Also update on first layout
updateLayout()
return () => {
observer.disconnect()
}
}, [updateLayout])
// Update after every render, too
useLayoutEffect(() => {
updateLayout()
})
return (
<div ref={ref} className="tlui-top-zone__container">
{children}
</div>
)
}
const DocumentNameEditor = track(function DocumentNameEditor({
state,
setState,
}: {
state: NameState
setState: (update: SetStateAction<NameState>) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
const editor = useEditor()
const documentSettings = editor.getDocumentSettings()
const msg = useTranslation()
const defaultDocumentName = msg('document.default-name')
useEffect(() => {
if (state.isEditing && inputRef.current) {
inputRef.current.select()
}
}, [state.isEditing])
useEffect(() => {
const save = () => {
if (state.name === null) return
const trimmed = state.name.trim()
if (trimmed === documentSettings.name.trim()) {
if (!state.isEditing) setState((prev) => ({ ...prev, name: null }))
return
}
editor.updateDocumentSettings({ name: trimmed })
}
if (!state.isEditing) {
save()
}
}, [documentSettings.name, editor, state.isEditing, state.name, setState])
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const value = e.currentTarget.value
setState((prev) => ({ ...prev, name: value }))
},
[setState]
)
const handleKeydownCapture = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
// blur triggers save
inputRef.current?.blur()
} else if (e.key === 'Escape') {
e.preventDefault()
// revert to original name instantly so that when we blur we don't
// trigger a save with the new one
setState((prev) => ({ ...prev, name: null }))
inputRef.current?.blur()
}
},
[setState]
)
const handleBlur = useCallback(() => {
setState((prev) => ({ ...prev, isEditing: false }))
}, [setState])
const name = state.name || documentSettings.name || defaultDocumentName
return (
<div className="tlui-document-name__input__wrapper">
{state.isEditing && (
<input
ref={inputRef}
className="tlui-document-name__input"
value={state.name ?? documentSettings.name}
onChange={handleChange}
onBlur={handleBlur}
onKeyDownCapture={handleKeydownCapture}
placeholder={defaultDocumentName}
autoFocus
/>
)}
{state.isEditing ? (
<div
className="tlui-document-name__text tlui-document-name__text__hidden"
// @ts-expect-error
inert=""
aria-hidden
>
{addRealSpaceForWhitespace(name)}
</div>
) : (
<div
className="tlui-document-name__text"
onDoubleClick={() => setState((prev) => ({ ...prev, isEditing: true }))}
>
{addRealSpaceForWhitespace(name)}
</div>
)}
</div>
)
})
function addRealSpaceForWhitespace(string: string) {
if (string === '') string = ' '
return string.replace(/ /g, '\u00a0')
}

View file

@ -23,7 +23,11 @@ export const ExportMenu = React.memo(function ExportMenu() {
const msg = useTranslation()
const handleUiEvent = useHandleUiEvents()
const editor = useEditor()
const saveFileCopyAction = getSaveFileCopyAction(editor, handleUiEvent)
const saveFileCopyAction = getSaveFileCopyAction(
editor,
handleUiEvent,
msg('document.default-name')
)
const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false)
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false)

View file

@ -14,6 +14,7 @@ import {
TldrawUiMenuGroup,
TldrawUiMenuItem,
atom,
debugFlags,
lns,
useActions,
useValue,
@ -32,6 +33,7 @@ import { CURSOR_CHAT_ACTION, useCursorChat } from '../utils/useCursorChat'
import { OPEN_FILE_ACTION, SAVE_FILE_COPY_ACTION, useFileSystem } from '../utils/useFileSystem'
import { useHandleUiEvents } from '../utils/useHandleUiEvent'
import { CursorChatBubble } from './CursorChatBubble'
import { DocumentTopZone } from './DocumentName/DocumentName'
import { EmbeddedInIFrameWarning } from './EmbeddedInIFrameWarning'
import { MultiplayerFileMenu } from './FileMenu'
import { Links } from './Links'
@ -84,8 +86,16 @@ const components: TLComponents = {
},
TopPanel: () => {
const isOffline = useValue('offline', () => shittyOfflineAtom.get(), [])
if (!isOffline) return null
const showDocumentName = useValue('documentName ', () => debugFlags.documentName.get(), [
debugFlags,
])
if (!showDocumentName) {
if (isOffline) {
return <OfflineIndicator />
}
return null
}
return <DocumentTopZone isOffline={isOffline} />
},
SharePanel: () => {
return (

View file

@ -232,7 +232,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
)
})
function getShareUrl(url: string, readonly: boolean) {
export function getShareUrl(url: string, readonly: boolean) {
if (!readonly) {
return url
}

View file

@ -27,7 +27,11 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
return useMemo((): TLUiOverrides => {
return {
actions(editor, actions, { addToast, msg, addDialog }) {
actions[SAVE_FILE_COPY_ACTION] = getSaveFileCopyAction(editor, handleUiEvent)
actions[SAVE_FILE_COPY_ACTION] = getSaveFileCopyAction(
editor,
handleUiEvent,
msg('document.default-name')
)
actions[OPEN_FILE_ACTION] = {
id: OPEN_FILE_ACTION,
label: 'action.open-file',
@ -94,7 +98,8 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
export function getSaveFileCopyAction(
editor: Editor,
handleUiEvent: TLUiEventHandler
handleUiEvent: TLUiEventHandler,
defaultDocumentName: string
): TLUiActionItem {
return {
id: SAVE_FILE_COPY_ACTION,
@ -103,7 +108,12 @@ export function getSaveFileCopyAction(
kbd: '$s',
async onSelect(source) {
handleUiEvent('save-project-to-file', { source })
const defaultName = saveFileNames.get(editor.store) || `Untitled${TLDRAW_FILE_EXTENSION}`
const documentName =
editor.getDocumentSettings().name === ''
? defaultDocumentName
: editor.getDocumentSettings().name
const defaultName =
saveFileNames.get(editor.store) || `${documentName}${TLDRAW_FILE_EXTENSION}`
const blobToSave = serializeTldrawJsonBlob(editor.store)
let handle

View file

@ -317,3 +317,71 @@
.tlui-layout[data-breakpoint='0'] .tlui-offline-indicator {
margin-top: 4px;
}
/* Document Name */
.tlui-document-name__inner {
border-radius: calc(var(--radius-2) + var(--space-2));
background-color: var(--color-background);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
height: 40px;
max-width: 100%;
color: var(--color-text);
text-shadow: 1px 1px 0px var(--color-background);
pointer-events: all;
}
.tlui-document-name__input__wrapper {
position: relative;
max-width: calc(100% - 40px);
display: flex;
flex: auto;
}
.tlui-document-name__input,
.tlui-document-name__text {
padding: var(--space-2) var(--space-3);
white-space: pre;
line-height: 24px;
min-width: 62px;
}
.tlui-document-name__input {
position: absolute;
width: 100%;
border-radius: var(--radius-2);
color: var(--color-text);
background: transparent;
}
.tlui-document-name__input:focus {
box-shadow: inset 0px 0px 0px 1px var(--color-selected);
border: none;
}
.tlui-document-name__text {
width: 100%;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tlui-document-name__text__hidden {
opacity: 0;
pointer-events: none;
}
.tlui-document-name__menu {
width: 40px;
height: 40px;
}
.tlui-document-name__input__wrapper:focus-within + .tlui-document-name__menu::after {
/* when the input is focused, its outline is flush against the menu button which doesn't look
good. This shifts the left-hand edge of the menu button over by 2px to give it a little
breathing room when the input is focused. */
width: calc(100% - 10px);
}

View file

@ -85,6 +85,7 @@ export class ScreenshotDragging extends StateNode {
editor,
shapes.map((s) => s.id),
'png',
'Screenshot',
{ bounds: box, background: editor.getInstanceState().exportBackground }
)
}

View file

@ -55,6 +55,7 @@
"action.print": "Print",
"action.redo": "Redo",
"action.remove-frame": "Remove frame",
"action.rename": "Rename",
"action.rotate-ccw": "Rotate counterclockwise",
"action.rotate-cw": "Rotate clockwise",
"action.save-copy": "Save a copy",
@ -113,6 +114,7 @@
"color-style.violet": "Violet",
"color-style.yellow": "Yellow",
"fill-style.none": "None",
"document.default-name": "Untitled",
"fill-style.semi": "Semi",
"fill-style.solid": "Solid",
"fill-style.pattern": "Pattern",

View file

@ -52,6 +52,7 @@ export const debugFlags: Record<string, DebugFlag<boolean>> = {
forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }),
documentName: createDebugValue('documentName', { defaults: { all: false } }),
}
declare global {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -70,6 +70,15 @@ function makeActions(actions: TLUiActionItem[]) {
return Object.fromEntries(actions.map((action) => [action.id, action])) as TLUiActionsContextType
}
function getExportName(editor: Editor, defaultName: string) {
const selectedShapes = editor.getSelectedShapes()
// When we don't have any shapes selected, we want to use the document name
if (selectedShapes.length === 0) {
return editor.getDocumentSettings().name || defaultName
}
return undefined
}
/** @internal */
export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
const editor = useEditor()
@ -83,6 +92,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
const { cut, copy, paste } = useMenuClipboardEvents()
const copyAs = useCopyAs()
const exportAs = useExportAs()
const defaultDocumentName = msg('document.default-name')
const trackEvent = useUiEvents()
@ -165,7 +175,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('export-as', { format: 'svg', source })
exportAs(editor.getSelectedShapeIds(), 'svg')
exportAs(editor.getSelectedShapeIds(), 'svg', getExportName(editor, defaultDocumentName))
},
},
{
@ -178,7 +188,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('export-as', { format: 'png', source })
exportAs(editor.getSelectedShapeIds(), 'png')
exportAs(editor.getSelectedShapeIds(), 'png', getExportName(editor, defaultDocumentName))
},
},
{
@ -191,7 +201,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('export-as', { format: 'json', source })
exportAs(editor.getSelectedShapeIds(), 'json')
exportAs(editor.getSelectedShapeIds(), 'json', getExportName(editor, defaultDocumentName))
},
},
{
@ -1213,6 +1223,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
clearToasts,
printSelectionOrPages,
msg,
defaultDocumentName,
])
return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider>

View file

@ -5,6 +5,7 @@ export type TLUiEventSource =
| 'menu'
| 'context-menu'
| 'zoom-menu'
| 'document-name'
| 'navigation-zone'
| 'quick-actions'
| 'actions-menu'

View file

@ -11,8 +11,8 @@ export function useExportAs() {
const msg = useTranslation()
return useCallback(
(ids: TLShapeId[], format: TLExportType = 'png') => {
exportAs(editor, ids, format, {
(ids: TLShapeId[], format: TLExportType = 'png', name: string | undefined) => {
exportAs(editor, ids, format, name, {
scale: 1,
background: editor.getInstanceState().exportBackground,
}).catch((e) => {

View file

@ -59,6 +59,7 @@ export type TLUiTranslationKey =
| 'action.print'
| 'action.redo'
| 'action.remove-frame'
| 'action.rename'
| 'action.rotate-ccw'
| 'action.rotate-cw'
| 'action.save-copy'
@ -117,6 +118,7 @@ export type TLUiTranslationKey =
| 'color-style.violet'
| 'color-style.yellow'
| 'fill-style.none'
| 'document.default-name'
| 'fill-style.semi'
| 'fill-style.solid'
| 'fill-style.pattern'

View file

@ -59,6 +59,7 @@ export const DEFAULT_TRANSLATION = {
'action.print': 'Print',
'action.redo': 'Redo',
'action.remove-frame': 'Remove frame',
'action.rename': 'Rename',
'action.rotate-ccw': 'Rotate counterclockwise',
'action.rotate-cw': 'Rotate clockwise',
'action.save-copy': 'Save a copy',
@ -117,6 +118,7 @@ export const DEFAULT_TRANSLATION = {
'color-style.violet': 'Violet',
'color-style.yellow': 'Yellow',
'fill-style.none': 'None',
'document.default-name': 'Untitled',
'fill-style.semi': 'Semi',
'fill-style.solid': 'Solid',
'fill-style.pattern': 'Pattern',

View file

@ -10,6 +10,7 @@ export type TLExportType = 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
* @param editor - The editor instance.
* @param ids - The ids of the shapes to export.
* @param format - The format to export as.
* @param name - Name of the exported file. If undefined a predefined name, based on the selection, will be used.
* @param opts - Options for the export.
*
* @public
@ -18,9 +19,12 @@ export async function exportAs(
editor: Editor,
ids: TLShapeId[],
format: TLExportType = 'png',
name: string | undefined,
opts = {} as Partial<TLSvgOptions>
) {
let name = `shapes at ${getTimestamp()}`
// If we don't get name then use a predefined one
if (!name) {
name = `shapes at ${getTimestamp()}`
if (ids.length === 1) {
const first = editor.getShape(ids[0])!
if (editor.isShapeOfType<TLFrameShape>(first, 'frame')) {
@ -29,6 +33,7 @@ export async function exportAs(
name = `${first.id.replace(/:/, '_')} at ${getTimestamp()}`
}
}
}
name += `.${format}`
const blob = await exportToBlob(editor, ids, format, opts)