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>
pull/1498/head
Lu Wilson 2023-06-01 19:46:26 +01:00 zatwierdzone przez GitHub
rodzic 941647fd1b
commit 3bc72cb822
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
20 zmienionych plików z 233 dodań i 19 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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