Add support for project names (#1340)

This PR adds some things that we need for the Project Name feature on
tldraw.com.
It should be reviewed alongside
https://github.com/tldraw/tldraw-lite/pull/1814


## Name Property
This PR adds a `name` property to `TLDocument`. We use this to store a
project's name.

<img width="454" alt="Screenshot 2023-05-09 at 15 47 26"
src="https://github.com/tldraw/tldraw/assets/15892272/f3be438e-aa0f-4dec-8f51-8dfd9f9d0ced">

## Top Zone
This PR adds a `topZone` area of the UI that we can add stuff to,
similar to how `shareZone` works.
It also adds an example to show where the `topZone` and `shareZone` are:

<img width="1511" alt="Screenshot 2023-05-12 at 10 57 40"
src="https://github.com/tldraw/tldraw/assets/15892272/f5e1cd33-017e-4aaf-bfee-4d85119e2974">

## Breakpoints
This PR change's the UI's breakpoints a little bit.
It moves the action bar to the bottom a little bit earlier.
(This gives us more space at the top for the project name).

![2023-05-12 at 11 08 26 - Fuchsia
Bison](https://github.com/tldraw/tldraw/assets/15892272/34563cea-b1d1-47be-ac5e-5650ee0ba02d)

![2023-05-12 at 13 45 04 - Tan
Mole](https://github.com/tldraw/tldraw/assets/15892272/ab190bd3-51d4-4a8b-88de-c72ab14bcba6)

## Input Blur
This PR adds an `onBlur` parameter to `Input`.
This was needed because 'clicking off' the input wasn't firing
`onComplete` or `onCancel`.

<img width="620" alt="Screenshot 2023-05-09 at 16 12 58"
src="https://github.com/tldraw/tldraw/assets/15892272/3b28da74-0a74-4063-8053-e59e47027caf">

## Create Project Name
This PR adds an internal `createProjectName` property to
`TldrawEditorConfig`.
Similar to `derivePresenceState`, you can pass a custom function to it.
It lets you control what gets used as the default project name. We use
it to set different names in our local projects compared to shared
projects.

In the future, when we add more advanced project features, we could
handle this better within the UI.

<img width="454" alt="Screenshot 2023-05-09 at 15 47 26"
src="https://github.com/tldraw/tldraw/assets/15892272/da9a4699-ac32-40d9-a97c-6c682acfac41">

### Test Plan

1. Gradually reduce the width of the browser window.
2. Check that the actions menu jumps to the bottom before the style
panel moves to the bottom.

---

1. In the examples app, open the `/zones` example.
2. Check that there's a 'top zone' at the top.

- [ ] Unit Tests
- [ ] Webdriver tests

### Release Note

- [dev] Added a `topZone` area where you can put stuff.
- [dev] Added a `name` property to `TLDocument` - and `app` methods for
it.
- [dev] Added an internal `createProjectName` config property for
controlling the default project name.
- [dev] Added an `onBlur` parameter to `Input`.
- Moved the actions bar to the bottom on medium-sized screens.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Lu Wilson 2023-06-01 19:46:26 +01:00 committed by GitHub
parent 941647fd1b
commit 3bc72cb822
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 233 additions and 19 deletions

View file

@ -0,0 +1,40 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
export default function Example() {
return (
<div className="tldraw__editor">
<Tldraw shareZone={<CustomShareZone />} topZone={<CustomTopZone />} />
</div>
)
}
function CustomShareZone() {
return (
<div
style={{
backgroundColor: 'var(--palette-light-blue)',
width: '100%',
textAlign: 'center',
minWidth: '80px',
}}
>
<p>Share Zone</p>
</div>
)
}
function CustomTopZone() {
return (
<div
style={{
width: '100%',
backgroundColor: 'var(--palette-light-green)',
textAlign: 'center',
}}
>
<p>Top Zone</p>
</div>
)
}

View file

@ -14,6 +14,7 @@ import UserPresenceExample from './11-user-presence/UserPresenceExample'
import UiEventsExample from './12-ui-events/UiEventsExample'
import StoreEventsExample from './13-store-events/StoreEventsExample'
import PersistenceExample from './14-persistence/PersistenceExample'
import ZonesExample from './15-custom-zones/ZonesExample'
import ExampleApi from './2-api/APIExample'
import CustomConfigExample from './3-custom-config/CustomConfigExample'
import CustomUiExample from './4-custom-ui/CustomUiExample'
@ -90,6 +91,10 @@ export const allExamples: Example[] = [
path: '/user-presence',
element: <UserPresenceExample />,
},
{
path: '/zones',
element: <ZonesExample />,
},
{
path: '/persistence',
element: <PersistenceExample />,

View file

@ -229,6 +229,7 @@
"share-menu.save-note": "Download this project to your computer as a .tldr file.",
"share-menu.fork-note": "Create a new shared project based on this snapshot.",
"share-menu.share-project": "Share this project",
"share-menu.default-project-name": "Shared Project",
"share-menu.copy-link": "Copy share link",
"share-menu.readonly-link": "Read-only",
"share-menu.create-snapshot-link": "Copy snapshot link",
@ -277,6 +278,12 @@
"shortcuts-dialog.tools": "Tools",
"shortcuts-dialog.transform": "Transform",
"shortcuts-dialog.view": "View",
"home-project-dialog.title": "Home project",
"home-project-dialog.description": "This is your local home project. It's just for you!",
"rename-project-dialog.title": "Rename project",
"rename-project-dialog.cancel": "Cancel",
"rename-project-dialog.rename": "Rename",
"home-project-dialog.ok": "Ok",
"style-panel.title": "Styles",
"style-panel.align": "Align",
"style-panel.vertical-align": "Vertical align",

View file

@ -390,6 +390,8 @@ export class App extends EventEmitter<TLEventMap> {
panZoomIntoView(ids: TLShapeId[], opts?: AnimationOptions): this;
// (undocumented)
popFocusLayer(): this;
// @internal (undocumented)
get projectName(): string;
// @internal
get props(): null | TLNullableShapeProps;
// (undocumented)
@ -471,6 +473,8 @@ export class App extends EventEmitter<TLEventMap> {
setLocale(locale: string): void;
// (undocumented)
setPenMode(isPenMode: boolean): this;
// @internal (undocumented)
setProjectName(name: string): void;
setProp(key: TLShapeProp, value: any, ephemeral?: boolean, squashing?: boolean): this;
// @internal (undocumented)
setReadOnly(isReadOnly: boolean): this;
@ -512,6 +516,8 @@ export class App extends EventEmitter<TLEventMap> {
updateAssets(assets: TLAssetPartial[]): this;
// @internal
updateCullingBounds(): this;
// @internal (undocumented)
updateDocumentSettings(settings: Partial<TLDocument>): void;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId' | 'documentId' | 'userId'>>, ephemeral?: boolean, squashing?: boolean): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this;
updateShapes(partials: (null | TLShapePartial | undefined)[], squashing?: boolean): this;
@ -1829,6 +1835,7 @@ export type TldrawEditorProps = {
initialData?: StoreSnapshot<TLRecord>;
instanceId?: TLInstanceId;
persistenceKey?: string;
defaultName?: string;
});
// @public (undocumented)

View file

@ -119,6 +119,10 @@ export type TldrawEditorProps = {
* The id under which to sync and persist the editor's data.
*/
persistenceKey?: string
/**
* The initial document name to use for the new store.
*/
defaultName?: string
}
)
@ -169,13 +173,14 @@ export const TldrawEditor = memo(function TldrawEditor(props: TldrawEditorProps)
})
function TldrawEditorWithOwnStore(props: TldrawEditorProps & { store: undefined }) {
const { initialData, instanceId = TAB_ID, shapes, persistenceKey } = props
const { defaultName, initialData, instanceId = TAB_ID, shapes, persistenceKey } = props
const syncedStore = useLocalStore({
customShapes: shapes,
instanceId,
initialData,
persistenceKey,
defaultName,
})
return <TldrawEditorWithLoadingStore {...props} store={syncedStore} />

View file

@ -35,6 +35,7 @@ import {
TLCursor,
TLCursorType,
TLDOCUMENT_ID,
TLDocument,
TLFrameShape,
TLGroupShape,
TLImageAsset,
@ -1515,10 +1516,25 @@ export class App extends EventEmitter<TLEventMap> {
return this.store.get(TLDOCUMENT_ID)!
}
/** @internal */
updateDocumentSettings(settings: Partial<TLDocument>) {
this.store.put([{ ...this.documentSettings, ...settings }])
}
get gridSize() {
return this.documentSettings.gridSize
}
/** @internal */
get projectName() {
return this.documentSettings.name
}
/** @internal */
setProjectName(name: string) {
this.updateDocumentSettings({ name })
}
get isSnapMode() {
return this.userDocumentSettings.isSnapMode
}

View file

@ -21,6 +21,7 @@ export type StoreOptions = {
customShapes?: Record<string, ShapeInfo>
instanceId?: TLInstanceId
initialData?: StoreSnapshot<TLRecord>
defaultName?: string
}
/**
@ -30,7 +31,12 @@ export type StoreOptions = {
*
* @public */
export function createTLStore(opts = {} as StoreOptions): TLStore {
const { customShapes = {}, instanceId = InstanceRecordType.createId(), initialData } = opts
const {
customShapes = {},
instanceId = InstanceRecordType.createId(),
initialData,
defaultName = '',
} = opts
return new Store({
schema: createTLSchema({ customShapes }),
@ -38,6 +44,7 @@ export function createTLStore(opts = {} as StoreOptions): TLStore {
props: {
instanceId,
documentId: TLDOCUMENT_ID,
defaultName,
},
})
}

View file

@ -5,11 +5,10 @@ import { usePrevious } from './usePrevious'
/** @public */
export function useTLStore(opts: StoreOptions) {
const [store, setStore] = useState(() => createTLStore(opts))
const previousOpts = usePrevious(opts)
const prev = usePrevious(opts)
if (
previousOpts.customShapes !== opts.customShapes ||
previousOpts.initialData !== opts.initialData ||
previousOpts.instanceId !== opts.instanceId
// shallow equality check
(Object.keys(prev) as (keyof StoreOptions)[]).some((key) => prev[key] !== opts[key])
) {
const newStore = createTLStore(opts)
setStore(newStore)

View file

@ -780,6 +780,8 @@ export type TLDefaultShape = TLArrowShape | TLBookmarkShape | TLDrawShape | TLEm
export interface TLDocument extends BaseRecord<'document', ID<TLDocument>> {
// (undocumented)
gridSize: number;
// (undocumented)
name: string;
}
// @public (undocumented)
@ -1250,6 +1252,7 @@ export type TLStore = Store<TLRecord, TLStoreProps>;
export type TLStoreProps = {
instanceId: TLInstanceId;
documentId: typeof TLDOCUMENT_ID;
defaultName: string;
};
// @public (undocumented)

View file

@ -40,6 +40,7 @@ export type TLStoreSnapshot = StoreSnapshot<TLRecord>
export type TLStoreProps = {
instanceId: TLInstanceId
documentId: typeof TLDOCUMENT_ID
defaultName: string
}
/** @public */
@ -91,7 +92,7 @@ export function createIntegrityChecker(store: TLStore): () => void {
const { instanceId: tabId } = store.props
// make sure we have exactly one document
if (!store.has(TLDOCUMENT_ID)) {
store.put([DocumentRecordType.create({ id: TLDOCUMENT_ID })])
store.put([DocumentRecordType.create({ id: TLDOCUMENT_ID, name: store.props.defaultName })])
return ensureStoreIsUsable()
}

View file

@ -3,6 +3,7 @@ import { structuredClone } from '@tldraw/utils'
import fs from 'fs'
import { imageAssetMigrations } from './assets/TLImageAsset'
import { videoAssetMigrations } from './assets/TLVideoAsset'
import { documentTypeMigrations } from './records/TLDocument'
import { instanceTypeMigrations, instanceTypeVersions } from './records/TLInstance'
import { instancePageStateMigrations } from './records/TLInstancePageState'
import { instancePresenceTypeMigrations } from './records/TLInstancePresence'
@ -644,6 +645,18 @@ describe('Adding instance_presence to the schema', () => {
})
})
describe('Adding name to document', () => {
const { up, down } = documentTypeMigrations.migrators[1]
test('up works as expected', () => {
expect(up({})).toEqual({ name: '' })
})
test('down works as expected', () => {
expect(down({ name: '' })).toEqual({})
})
})
describe('Adding check-box to geo shape', () => {
const { up, down } = geoShapeTypeMigrations.migrators[4]

View file

@ -8,6 +8,7 @@ import { T } from '@tldraw/tlvalidate'
*/
export interface TLDocument extends BaseRecord<'document', ID<TLDocument>> {
gridSize: number
name: string
}
/** @public */
@ -17,22 +18,46 @@ export const documentTypeValidator: T.Validator<TLDocument> = T.model(
typeName: T.literal('document'),
id: T.literal('document:document' as ID<TLDocument>),
gridSize: T.number,
name: T.string,
})
)
// --- MIGRATIONS ---
// STEP 1: Add a new version number here, give it a meaningful name.
// It should be 1 higher than the current version
const Versions = {
AddName: 1,
} as const
/** @public */
export const documentTypeMigrations = defineMigrations({
// STEP 2: Update the current version to point to your latest version
currentVersion: Versions.AddName,
// STEP 3: Add an up+down migration for the new version here
migrators: {
[Versions.AddName]: {
up: (document: TLDocument) => {
return { ...document, name: '' }
},
down: ({ name: _, ...document }: TLDocument) => {
return document
},
},
},
})
/** @public */
export const DocumentRecordType = createRecordType<TLDocument>('document', {
migrations: documentTypeMigrations,
validator: documentTypeValidator,
scope: 'document',
}).withDefaultProperties(
(): Omit<TLDocument, 'id' | 'typeName'> => ({
gridSize: 10,
name: '',
})
)
// all document records have the same ID: 'document:document'
/** @public */
export const TLDOCUMENT_ID: ID<TLDocument> = DocumentRecordType.createCustomId('document')
/** @public */
export const documentTypeMigrations = defineMigrations({})

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,7 @@ export type TldrawUiProps = {
hideUi?: boolean
/** A component to use for the share zone (will be deprecated) */
shareZone?: ReactNode
topZone?: ReactNode
/** Additional items to add to the debug menu (will be deprecated)*/
renderDebugMenuItems?: () => React.ReactNode
} & TldrawUiContextProviderProps
@ -40,6 +41,7 @@ export type TldrawUiProps = {
*/
export const TldrawUi = React.memo(function TldrawUi({
shareZone,
topZone,
renderDebugMenuItems,
children,
hideUi,
@ -50,6 +52,7 @@ export const TldrawUi = React.memo(function TldrawUi({
<TldrawUiInner
hideUi={hideUi}
shareZone={shareZone}
topZone={topZone}
renderDebugMenuItems={renderDebugMenuItems}
>
{children}
@ -61,6 +64,7 @@ export const TldrawUi = React.memo(function TldrawUi({
type TldrawUiContentProps = {
hideUi?: boolean
shareZone?: ReactNode
topZone?: ReactNode
renderDebugMenuItems?: () => React.ReactNode
}
@ -84,6 +88,7 @@ const TldrawUiInner = React.memo(function TldrawUiInner({
/** @public */
export const TldrawUiContent = React.memo(function TldrawUI({
shareZone,
topZone,
renderDebugMenuItems,
}: TldrawUiContentProps) {
const app = useApp()
@ -127,12 +132,9 @@ export const TldrawUiContent = React.memo(function TldrawUI({
<StopFollowing />
</div>
</div>
<div className="tlui-layout__top__center">{topZone}</div>
<div className="tlui-layout__top__right">
{shareZone && (
<div className="tlui-share-zone" draggable={false}>
{shareZone}
</div>
)}
{shareZone}
{breakpoint >= 5 && !isReadonlyMode && (
<div className="tlui-style-panel__wrapper">
<StylePanel />

View file

@ -24,7 +24,7 @@ export const MenuZone = track(function MenuZone() {
<Menu />
<div className="tlui-menu-zone__divider" />
<PageMenu />
{breakpoint >= 5 && showQuickActions && (
{breakpoint >= 6 && showQuickActions && (
<>
<div className="tlui-menu-zone__divider" />
<UndoButton />

View file

@ -115,7 +115,7 @@ export const Toolbar = function Toolbar() {
'tlui-toolbar__extras__hidden': !showExtraActions,
})}
>
{breakpoint < 5 && (
{breakpoint < 6 && (
<div className="tlui-toolbar__extras__controls">
<UndoButton />
<RedoButton />

View file

@ -19,6 +19,7 @@ export interface InputProps {
onComplete?: (value: string) => void
onValueChange?: (value: string) => void
onCancel?: (value: string) => void
onBlur?: (value: string) => void
className?: string
/**
* Usually on iOS when you focus an input, the browser will adjust the viewport to bring the input
@ -46,6 +47,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(function Inp
onComplete,
onValueChange,
onCancel,
onBlur,
shouldManuallyMaintainScrollPositionWhenFocused = false,
children,
value,
@ -106,7 +108,14 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(function Inp
[onComplete, onCancel]
)
const handleBlur = React.useCallback(() => setIsFocused(false), [])
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(false)
const value = e.currentTarget.value
onBlur?.(value)
},
[onBlur]
)
React.useEffect(() => {
const visualViewport = window.visualViewport

View file

@ -233,6 +233,7 @@ export type TLTranslationKey =
| 'share-menu.save-note'
| 'share-menu.fork-note'
| 'share-menu.share-project'
| 'share-menu.default-project-name'
| 'share-menu.copy-link'
| 'share-menu.readonly-link'
| 'share-menu.create-snapshot-link'
@ -281,6 +282,12 @@ export type TLTranslationKey =
| 'shortcuts-dialog.tools'
| 'shortcuts-dialog.transform'
| 'shortcuts-dialog.view'
| 'home-project-dialog.title'
| 'home-project-dialog.description'
| 'rename-project-dialog.title'
| 'rename-project-dialog.cancel'
| 'rename-project-dialog.rename'
| 'home-project-dialog.ok'
| 'style-panel.title'
| 'style-panel.align'
| 'style-panel.vertical-align'

View file

@ -233,6 +233,7 @@ export const DEFAULT_TRANSLATION = {
'share-menu.save-note': 'Download this project to your computer as a .tldr file.',
'share-menu.fork-note': 'Create a new shared project based on this snapshot.',
'share-menu.share-project': 'Share this project',
'share-menu.default-project-name': 'Shared Project',
'share-menu.copy-link': 'Copy share link',
'share-menu.readonly-link': 'Read-only',
'share-menu.create-snapshot-link': 'Copy snapshot link',
@ -284,6 +285,12 @@ export const DEFAULT_TRANSLATION = {
'shortcuts-dialog.tools': 'Tools',
'shortcuts-dialog.transform': 'Transform',
'shortcuts-dialog.view': 'View',
'home-project-dialog.title': 'Home project',
'home-project-dialog.description': "This is your local home project. It's just for you!",
'rename-project-dialog.title': 'Rename project',
'rename-project-dialog.cancel': 'Cancel',
'rename-project-dialog.rename': 'Rename',
'home-project-dialog.ok': 'Ok',
'style-panel.title': 'Styles',
'style-panel.align': 'Align',
'style-panel.vertical-align': 'Vertical align',

View file

@ -28,6 +28,7 @@
grid-column: 1;
grid-row: 1;
display: flex;
min-width: 0px;
}
.tlui-layout__top__left {
@ -37,6 +38,19 @@
justify-content: flex-start;
width: 100%;
height: 100%;
flex-shrink: 1;
}
.tlui-layout__top__center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
margin-left: var(--space-2);
flex-grow: 1;
min-width: 0px;
}
.tlui-layout__top__right {
@ -46,6 +60,8 @@
justify-content: flex-start;
width: 100%;
height: 100%;
flex-shrink: 1;
min-width: 0px;
}
.scrollable,
@ -1627,6 +1643,47 @@
}
}
/* ------------------ Project Menu ------------------ */
.tlui-project-menu__wrapper {
display: flex;
width: 100%;
align-items: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
min-width: 0px;
}
.tlui-project-menu__button {
display: flex;
gap: var(--space-4);
pointer-events: all;
}
.tlui-project-menu__button__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0px;
}
.tlui-project-menu__input {
min-width: 0px;
text-align: center;
pointer-events: all;
/* Position slightly to the right so that it doesn't jump around */
/* 40px is the width of the icon */
margin-left: 40px;
}
.tlui-rename-project-dialog__input {
background-color: var(--color-muted-2);
flex-grow: 2;
border-radius: var(--radius-2);
padding: 0px var(--space-4);
}
/* ------------------- Navigation ------------------- */
.tlui-navigation-zone {