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:
parent
1e7af3a3e0
commit
b3d6af4454
17 changed files with 473 additions and 35 deletions
301
apps/dotcom/src/components/DocumentName/DocumentName.tsx
Normal file
301
apps/dotcom/src/components/DocumentName/DocumentName.tsx
Normal 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')
|
||||||
|
}
|
|
@ -23,7 +23,11 @@ export const ExportMenu = React.memo(function ExportMenu() {
|
||||||
const msg = useTranslation()
|
const msg = useTranslation()
|
||||||
const handleUiEvent = useHandleUiEvents()
|
const handleUiEvent = useHandleUiEvents()
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
const saveFileCopyAction = getSaveFileCopyAction(editor, handleUiEvent)
|
const saveFileCopyAction = getSaveFileCopyAction(
|
||||||
|
editor,
|
||||||
|
handleUiEvent,
|
||||||
|
msg('document.default-name')
|
||||||
|
)
|
||||||
const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false)
|
const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false)
|
||||||
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false)
|
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false)
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
TldrawUiMenuGroup,
|
TldrawUiMenuGroup,
|
||||||
TldrawUiMenuItem,
|
TldrawUiMenuItem,
|
||||||
atom,
|
atom,
|
||||||
|
debugFlags,
|
||||||
lns,
|
lns,
|
||||||
useActions,
|
useActions,
|
||||||
useValue,
|
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 { OPEN_FILE_ACTION, SAVE_FILE_COPY_ACTION, useFileSystem } from '../utils/useFileSystem'
|
||||||
import { useHandleUiEvents } from '../utils/useHandleUiEvent'
|
import { useHandleUiEvents } from '../utils/useHandleUiEvent'
|
||||||
import { CursorChatBubble } from './CursorChatBubble'
|
import { CursorChatBubble } from './CursorChatBubble'
|
||||||
|
import { DocumentTopZone } from './DocumentName/DocumentName'
|
||||||
import { EmbeddedInIFrameWarning } from './EmbeddedInIFrameWarning'
|
import { EmbeddedInIFrameWarning } from './EmbeddedInIFrameWarning'
|
||||||
import { MultiplayerFileMenu } from './FileMenu'
|
import { MultiplayerFileMenu } from './FileMenu'
|
||||||
import { Links } from './Links'
|
import { Links } from './Links'
|
||||||
|
@ -84,8 +86,16 @@ const components: TLComponents = {
|
||||||
},
|
},
|
||||||
TopPanel: () => {
|
TopPanel: () => {
|
||||||
const isOffline = useValue('offline', () => shittyOfflineAtom.get(), [])
|
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 <OfflineIndicator />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <DocumentTopZone isOffline={isOffline} />
|
||||||
},
|
},
|
||||||
SharePanel: () => {
|
SharePanel: () => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -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) {
|
if (!readonly) {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,11 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
|
||||||
return useMemo((): TLUiOverrides => {
|
return useMemo((): TLUiOverrides => {
|
||||||
return {
|
return {
|
||||||
actions(editor, actions, { addToast, msg, addDialog }) {
|
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] = {
|
actions[OPEN_FILE_ACTION] = {
|
||||||
id: OPEN_FILE_ACTION,
|
id: OPEN_FILE_ACTION,
|
||||||
label: 'action.open-file',
|
label: 'action.open-file',
|
||||||
|
@ -94,7 +98,8 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
|
||||||
|
|
||||||
export function getSaveFileCopyAction(
|
export function getSaveFileCopyAction(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
handleUiEvent: TLUiEventHandler
|
handleUiEvent: TLUiEventHandler,
|
||||||
|
defaultDocumentName: string
|
||||||
): TLUiActionItem {
|
): TLUiActionItem {
|
||||||
return {
|
return {
|
||||||
id: SAVE_FILE_COPY_ACTION,
|
id: SAVE_FILE_COPY_ACTION,
|
||||||
|
@ -103,7 +108,12 @@ export function getSaveFileCopyAction(
|
||||||
kbd: '$s',
|
kbd: '$s',
|
||||||
async onSelect(source) {
|
async onSelect(source) {
|
||||||
handleUiEvent('save-project-to-file', { 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)
|
const blobToSave = serializeTldrawJsonBlob(editor.store)
|
||||||
let handle
|
let handle
|
||||||
|
|
|
@ -317,3 +317,71 @@
|
||||||
.tlui-layout[data-breakpoint='0'] .tlui-offline-indicator {
|
.tlui-layout[data-breakpoint='0'] .tlui-offline-indicator {
|
||||||
margin-top: 4px;
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -85,6 +85,7 @@ export class ScreenshotDragging extends StateNode {
|
||||||
editor,
|
editor,
|
||||||
shapes.map((s) => s.id),
|
shapes.map((s) => s.id),
|
||||||
'png',
|
'png',
|
||||||
|
'Screenshot',
|
||||||
{ bounds: box, background: editor.getInstanceState().exportBackground }
|
{ bounds: box, background: editor.getInstanceState().exportBackground }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,7 @@
|
||||||
"action.print": "Print",
|
"action.print": "Print",
|
||||||
"action.redo": "Redo",
|
"action.redo": "Redo",
|
||||||
"action.remove-frame": "Remove frame",
|
"action.remove-frame": "Remove frame",
|
||||||
|
"action.rename": "Rename",
|
||||||
"action.rotate-ccw": "Rotate counterclockwise",
|
"action.rotate-ccw": "Rotate counterclockwise",
|
||||||
"action.rotate-cw": "Rotate clockwise",
|
"action.rotate-cw": "Rotate clockwise",
|
||||||
"action.save-copy": "Save a copy",
|
"action.save-copy": "Save a copy",
|
||||||
|
@ -113,6 +114,7 @@
|
||||||
"color-style.violet": "Violet",
|
"color-style.violet": "Violet",
|
||||||
"color-style.yellow": "Yellow",
|
"color-style.yellow": "Yellow",
|
||||||
"fill-style.none": "None",
|
"fill-style.none": "None",
|
||||||
|
"document.default-name": "Untitled",
|
||||||
"fill-style.semi": "Semi",
|
"fill-style.semi": "Semi",
|
||||||
"fill-style.solid": "Solid",
|
"fill-style.solid": "Solid",
|
||||||
"fill-style.pattern": "Pattern",
|
"fill-style.pattern": "Pattern",
|
||||||
|
|
|
@ -52,6 +52,7 @@ export const debugFlags: Record<string, DebugFlag<boolean>> = {
|
||||||
forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
|
forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
|
||||||
debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
|
debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
|
||||||
hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }),
|
hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }),
|
||||||
|
documentName: createDebugValue('documentName', { defaults: { all: false } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -70,6 +70,15 @@ function makeActions(actions: TLUiActionItem[]) {
|
||||||
return Object.fromEntries(actions.map((action) => [action.id, action])) as TLUiActionsContextType
|
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 */
|
/** @internal */
|
||||||
export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
@ -83,6 +92,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
||||||
const { cut, copy, paste } = useMenuClipboardEvents()
|
const { cut, copy, paste } = useMenuClipboardEvents()
|
||||||
const copyAs = useCopyAs()
|
const copyAs = useCopyAs()
|
||||||
const exportAs = useExportAs()
|
const exportAs = useExportAs()
|
||||||
|
const defaultDocumentName = msg('document.default-name')
|
||||||
|
|
||||||
const trackEvent = useUiEvents()
|
const trackEvent = useUiEvents()
|
||||||
|
|
||||||
|
@ -165,7 +175,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
||||||
readonlyOk: true,
|
readonlyOk: true,
|
||||||
onSelect(source) {
|
onSelect(source) {
|
||||||
trackEvent('export-as', { format: 'svg', 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,
|
readonlyOk: true,
|
||||||
onSelect(source) {
|
onSelect(source) {
|
||||||
trackEvent('export-as', { format: 'png', 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,
|
readonlyOk: true,
|
||||||
onSelect(source) {
|
onSelect(source) {
|
||||||
trackEvent('export-as', { format: 'json', 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,
|
clearToasts,
|
||||||
printSelectionOrPages,
|
printSelectionOrPages,
|
||||||
msg,
|
msg,
|
||||||
|
defaultDocumentName,
|
||||||
])
|
])
|
||||||
|
|
||||||
return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider>
|
return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider>
|
||||||
|
|
|
@ -5,6 +5,7 @@ export type TLUiEventSource =
|
||||||
| 'menu'
|
| 'menu'
|
||||||
| 'context-menu'
|
| 'context-menu'
|
||||||
| 'zoom-menu'
|
| 'zoom-menu'
|
||||||
|
| 'document-name'
|
||||||
| 'navigation-zone'
|
| 'navigation-zone'
|
||||||
| 'quick-actions'
|
| 'quick-actions'
|
||||||
| 'actions-menu'
|
| 'actions-menu'
|
||||||
|
|
|
@ -11,8 +11,8 @@ export function useExportAs() {
|
||||||
const msg = useTranslation()
|
const msg = useTranslation()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(ids: TLShapeId[], format: TLExportType = 'png') => {
|
(ids: TLShapeId[], format: TLExportType = 'png', name: string | undefined) => {
|
||||||
exportAs(editor, ids, format, {
|
exportAs(editor, ids, format, name, {
|
||||||
scale: 1,
|
scale: 1,
|
||||||
background: editor.getInstanceState().exportBackground,
|
background: editor.getInstanceState().exportBackground,
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
|
|
|
@ -59,6 +59,7 @@ export type TLUiTranslationKey =
|
||||||
| 'action.print'
|
| 'action.print'
|
||||||
| 'action.redo'
|
| 'action.redo'
|
||||||
| 'action.remove-frame'
|
| 'action.remove-frame'
|
||||||
|
| 'action.rename'
|
||||||
| 'action.rotate-ccw'
|
| 'action.rotate-ccw'
|
||||||
| 'action.rotate-cw'
|
| 'action.rotate-cw'
|
||||||
| 'action.save-copy'
|
| 'action.save-copy'
|
||||||
|
@ -117,6 +118,7 @@ export type TLUiTranslationKey =
|
||||||
| 'color-style.violet'
|
| 'color-style.violet'
|
||||||
| 'color-style.yellow'
|
| 'color-style.yellow'
|
||||||
| 'fill-style.none'
|
| 'fill-style.none'
|
||||||
|
| 'document.default-name'
|
||||||
| 'fill-style.semi'
|
| 'fill-style.semi'
|
||||||
| 'fill-style.solid'
|
| 'fill-style.solid'
|
||||||
| 'fill-style.pattern'
|
| 'fill-style.pattern'
|
||||||
|
|
|
@ -59,6 +59,7 @@ export const DEFAULT_TRANSLATION = {
|
||||||
'action.print': 'Print',
|
'action.print': 'Print',
|
||||||
'action.redo': 'Redo',
|
'action.redo': 'Redo',
|
||||||
'action.remove-frame': 'Remove frame',
|
'action.remove-frame': 'Remove frame',
|
||||||
|
'action.rename': 'Rename',
|
||||||
'action.rotate-ccw': 'Rotate counterclockwise',
|
'action.rotate-ccw': 'Rotate counterclockwise',
|
||||||
'action.rotate-cw': 'Rotate clockwise',
|
'action.rotate-cw': 'Rotate clockwise',
|
||||||
'action.save-copy': 'Save a copy',
|
'action.save-copy': 'Save a copy',
|
||||||
|
@ -117,6 +118,7 @@ export const DEFAULT_TRANSLATION = {
|
||||||
'color-style.violet': 'Violet',
|
'color-style.violet': 'Violet',
|
||||||
'color-style.yellow': 'Yellow',
|
'color-style.yellow': 'Yellow',
|
||||||
'fill-style.none': 'None',
|
'fill-style.none': 'None',
|
||||||
|
'document.default-name': 'Untitled',
|
||||||
'fill-style.semi': 'Semi',
|
'fill-style.semi': 'Semi',
|
||||||
'fill-style.solid': 'Solid',
|
'fill-style.solid': 'Solid',
|
||||||
'fill-style.pattern': 'Pattern',
|
'fill-style.pattern': 'Pattern',
|
||||||
|
|
|
@ -10,6 +10,7 @@ export type TLExportType = 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
|
||||||
* @param editor - The editor instance.
|
* @param editor - The editor instance.
|
||||||
* @param ids - The ids of the shapes to export.
|
* @param ids - The ids of the shapes to export.
|
||||||
* @param format - The format to export as.
|
* @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.
|
* @param opts - Options for the export.
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
|
@ -18,9 +19,12 @@ export async function exportAs(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
ids: TLShapeId[],
|
ids: TLShapeId[],
|
||||||
format: TLExportType = 'png',
|
format: TLExportType = 'png',
|
||||||
|
name: string | undefined,
|
||||||
opts = {} as Partial<TLSvgOptions>
|
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) {
|
if (ids.length === 1) {
|
||||||
const first = editor.getShape(ids[0])!
|
const first = editor.getShape(ids[0])!
|
||||||
if (editor.isShapeOfType<TLFrameShape>(first, 'frame')) {
|
if (editor.isShapeOfType<TLFrameShape>(first, 'frame')) {
|
||||||
|
@ -29,6 +33,7 @@ export async function exportAs(
|
||||||
name = `${first.id.replace(/:/, '_')} at ${getTimestamp()}`
|
name = `${first.id.replace(/:/, '_')} at ${getTimestamp()}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
name += `.${format}`
|
name += `.${format}`
|
||||||
|
|
||||||
const blob = await exportToBlob(editor, ids, format, opts)
|
const blob = await exportToBlob(editor, ids, format, opts)
|
||||||
|
|
Loading…
Reference in a new issue