kopia lustrzana https://github.com/Tldraw/Tldraw
323 wiersze
8.8 KiB
TypeScript
323 wiersze
8.8 KiB
TypeScript
import {
|
|
ChangeEvent,
|
|
KeyboardEvent,
|
|
ReactNode,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import {
|
|
OfflineIndicator,
|
|
TLUiTranslationKey,
|
|
TldrawUiButton,
|
|
TldrawUiButtonIcon,
|
|
TldrawUiDropdownMenuContent,
|
|
TldrawUiDropdownMenuGroup,
|
|
TldrawUiDropdownMenuItem,
|
|
TldrawUiDropdownMenuRoot,
|
|
TldrawUiDropdownMenuTrigger,
|
|
TldrawUiKbd,
|
|
track,
|
|
useActions,
|
|
useBreakpoint,
|
|
useEditor,
|
|
useTranslation,
|
|
} from 'tldraw'
|
|
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 STYLE_PANEL_WIDTH = 148
|
|
const MARGIN_BETWEEN_ZONES = 12
|
|
// the maximum amount the people menu will extend from the style panel
|
|
const SQUEEZE_FACTOR = 52
|
|
|
|
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 breakpoint = useBreakpoint()
|
|
|
|
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 button width
|
|
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 maxWidth = Math.min(
|
|
totalWidth - rightWidth - leftWidth - 2 * MARGIN_BETWEEN_ZONES,
|
|
MAX_TITLE_WIDTH_PX
|
|
)
|
|
const xCoord = Math.max(xCoordIfCentered, xCoordIfLeftAligned) - left
|
|
|
|
// Squeeze the title if the right panel is too wide on small screens
|
|
if (rightPanel.offsetWidth > STYLE_PANEL_WIDTH && breakpoint <= 6) {
|
|
element.style.setProperty('max-width', maxWidth - SQUEEZE_FACTOR + 'px')
|
|
} else {
|
|
element.style.setProperty('max-width', maxWidth + 'px')
|
|
}
|
|
element.style.setProperty('transform', `translate(${xCoord}px, 0px)`)
|
|
}, [breakpoint])
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
if (documentSettings.name) {
|
|
document.title = `${documentSettings.name} · tldraw`
|
|
} else {
|
|
document.title = 'tldraw'
|
|
}
|
|
}, [documentSettings.name])
|
|
|
|
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={() => {
|
|
editor.setEditingShape(null)
|
|
setState((prev) => ({ ...prev, isEditing: true }))
|
|
}}
|
|
>
|
|
{addRealSpaceForWhitespace(name)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
function addRealSpaceForWhitespace(string: string) {
|
|
if (string === '') string = ' '
|
|
return string.replace(/ /g, '\u00a0')
|
|
}
|