From 315112459c36caaea86e48fa153c42ef457cdf1c Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 8 Jul 2022 14:09:08 +0100 Subject: [PATCH] 1.20.0 (#797) * Edit Farsi translations (#788) * Add a Ukrainian translation (#786) * Add a Ukrainian translation * Clarify some strings in the Ukrainian translation * feat: change dock position (#774) * feat: change dock position * fix grid row and column * add top position * fix responsive for the top position * change content side * fix overflowing menu * [improvement] theme on body (#790) * Update Tldraw.tsx * Add theme on body, adjust dark page options dialog * fix test * Preparing for global integration (#775) * Update translations.ts * Create en.json * Make main translation default * Remove unused locale property of translation Co-authored-by: Steve Ruiz * Fix language menu * Update ar.json (#793) * feature/add Hebrew translations (#792) * hebrew translations * pr fixes Co-authored-by: Steve Ruiz * fix toolspanel item position (#791) * fix toolspanel item position * add translation Co-authored-by: Steve Ruiz * Add remote caching * Adds link to translation guide (#794) * Update ar.json (#795) * [feature] readonly link (#796) * Copy readonly link * Update [id].tsx * Add readonly label * update psuedohash * Update utils.ts Co-authored-by: Baahar Ebrahimi <108254874+Baahaarmast@users.noreply.github.com> Co-authored-by: walking-octopus <46994949+walking-octopus@users.noreply.github.com> Co-authored-by: Judicael <46365844+judicaelandria@users.noreply.github.com> Co-authored-by: Ali Alhaidary <75235623+ali-alhaidary@users.noreply.github.com> Co-authored-by: gadi246 --- .../components/ReadOnlyMultiplayerEditor.tsx | 71 ++++++++ apps/www/hooks/useReadOnlyMultiplayerState.ts | 151 ++++++++++++++++++ apps/www/pages/v/[id].tsx | 31 ++++ packages/core/src/utils/utils.ts | 15 ++ .../MultiplayerMenu/MultiplayerMenu.tsx | 23 ++- .../src/components/TopPanel/TopPanel.tsx | 32 +++- packages/tldraw/src/state/TldrawApp.ts | 3 +- .../src/state/tools/SelectTool/SelectTool.ts | 13 +- packages/tldraw/src/translations/ar.json | 1 + packages/tldraw/src/translations/main.json | 25 +-- .../tldraw/src/translations/translations.ts | 2 +- 11 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 apps/www/components/ReadOnlyMultiplayerEditor.tsx create mode 100644 apps/www/hooks/useReadOnlyMultiplayerState.ts create mode 100644 apps/www/pages/v/[id].tsx diff --git a/apps/www/components/ReadOnlyMultiplayerEditor.tsx b/apps/www/components/ReadOnlyMultiplayerEditor.tsx new file mode 100644 index 000000000..8e4819ac3 --- /dev/null +++ b/apps/www/components/ReadOnlyMultiplayerEditor.tsx @@ -0,0 +1,71 @@ +import { RoomProvider } from '../utils/liveblocks' +import { Tldraw, useFileSystem } from '@tldraw/tldraw' +import { useAccountHandlers } from 'hooks/useAccountHandlers' +import { useMultiplayerAssets } from 'hooks/useMultiplayerAssets' +import { useMultiplayerState } from 'hooks/useMultiplayerState' +import { useUploadAssets } from 'hooks/useUploadAssets' +import React, { FC } from 'react' +import { styled } from 'styles' +import { useReadOnlyMultiplayerState } from 'hooks/useReadOnlyMultiplayerState' + +interface Props { + roomId: string + isUser: boolean + isSponsor: boolean +} + +const ReadOnlyMultiplayerEditor: FC = ({ + roomId, + isUser = false, + isSponsor = false, +}: { + roomId: string + isUser: boolean + isSponsor: boolean +}) => { + return ( + + + + ) +} + +// Inner Editor + +function ReadOnlyEditor({ roomId, isUser, isSponsor }: Props) { + const { onSaveProjectAs, onSaveProject } = useFileSystem() + const { onSignIn, onSignOut } = useAccountHandlers() + const { error, ...events } = useReadOnlyMultiplayerState(roomId) + + if (error) return Error: {error.message} + + return ( +
+ +
+ ) +} + +export default ReadOnlyMultiplayerEditor + +const LoadingScreen = styled('div', { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}) diff --git a/apps/www/hooks/useReadOnlyMultiplayerState.ts b/apps/www/hooks/useReadOnlyMultiplayerState.ts new file mode 100644 index 000000000..328e75295 --- /dev/null +++ b/apps/www/hooks/useReadOnlyMultiplayerState.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import React, { useState, useRef, useCallback } from 'react' +import type { TldrawApp, TDUser, TDShape, TDBinding, TDAsset } from '@tldraw/tldraw' +import { Storage, useRedo, useUndo, useRoom, useUpdateMyPresence } from '../utils/liveblocks' +import { useHotkeys } from 'react-hotkeys-hook' +import { LiveMap } from '@liveblocks/client' + +declare const window: Window & { app: TldrawApp } + +export function useReadOnlyMultiplayerState(roomId: string) { + const [app, setApp] = useState() + const [error, setError] = useState() + const [loading, setLoading] = useState(true) + + const room = useRoom() + const onUndo = useUndo() + const onRedo = useRedo() + const updateMyPresence = useUpdateMyPresence() + + const rIsPaused = useRef(false) + + const rLiveShapes = useRef() + const rLiveBindings = useRef() + const rLiveAssets = useRef() + + // Callbacks -------------- + + // Put the state into the window, for debugging. + const onMount = useCallback( + (app: TldrawApp) => { + app.loadRoom(roomId) + app.pause() // Turn off the app's own undo / redo stack + window.app = app + setApp(app) + }, + [roomId] + ) + + // Handle presence updates when the user's pointer / selection changes + const onChangePresence = useCallback( + (app: TldrawApp, user: TDUser) => { + updateMyPresence({ id: app.room?.userId, user }) + }, + [updateMyPresence] + ) + + // Document Changes -------- + + React.useEffect(() => { + const unsubs: (() => void)[] = [] + if (!(app && room)) return + // Handle errors + unsubs.push(room.subscribe('error', (error) => setError(error))) + + // Handle changes to other users' presence + unsubs.push( + room.subscribe('others', (others, event) => { + if (event.type === 'leave') { + if (event.user.presence) { + app?.removeUser(event.user.presence.id) + } + } else { + app.updateUsers( + others + .toArray() + .filter((other) => other.presence) + .map((other) => other.presence!.user) + .filter(Boolean) + ) + } + }) + ) + + let stillAlive = true + + // Setup the document's storage and subscriptions + async function setupDocument() { + const storage = await room.getStorage() + + // Migrate previous versions + const version = storage.root.get('version') + + // Initialize (get or create) maps for shapes/bindings/assets + + let lShapes = storage.root.get('shapes') + if (!lShapes || !('_serialize' in lShapes)) { + storage.root.set('shapes', new LiveMap()) + lShapes = storage.root.get('shapes') + } + rLiveShapes.current = lShapes + + let lBindings = storage.root.get('bindings') + if (!lBindings || !('_serialize' in lBindings)) { + storage.root.set('bindings', new LiveMap()) + lBindings = storage.root.get('bindings') + } + rLiveBindings.current = lBindings + + let lAssets = storage.root.get('assets') + if (!lAssets || !('_serialize' in lAssets)) { + storage.root.set('assets', new LiveMap()) + lAssets = storage.root.get('assets') + } + rLiveAssets.current = lAssets + + // Save the version number for future migrations + storage.root.set('version', 2.1) + + // Subscribe to changes + const handleChanges = () => { + app?.replacePageContent( + Object.fromEntries(lShapes.entries()), + Object.fromEntries(lBindings.entries()), + Object.fromEntries(lAssets.entries()) + ) + } + + if (stillAlive) { + unsubs.push(room.subscribe(lShapes, handleChanges)) + + // Update the document with initial content + handleChanges() + + // Zoom to fit the content + app.zoomToFit() + if (app.zoom > 1) { + app.resetZoom() + } + + setLoading(false) + } + } + + setupDocument() + + return () => { + stillAlive = false + unsubs.forEach((unsub) => unsub()) + } + }, [room, app]) + + return { + onUndo, + onRedo, + onMount, + onChangePresence, + error, + loading, + } +} diff --git a/apps/www/pages/v/[id].tsx b/apps/www/pages/v/[id].tsx new file mode 100644 index 000000000..17bee8df9 --- /dev/null +++ b/apps/www/pages/v/[id].tsx @@ -0,0 +1,31 @@ +import * as React from 'react' +import type { GetServerSideProps } from 'next' +import { getSession } from 'next-auth/react' +import dynamic from 'next/dynamic' +import { Utils } from '@tldraw/core' +const ReadOnlyMultiplayerEditor = dynamic(() => import('components/ReadOnlyMultiplayerEditor'), { + ssr: false, +}) as any + +interface RoomProps { + id: string + isSponsor: boolean + isUser: boolean +} + +export default function Room({ id, isUser, isSponsor }: RoomProps) { + return +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const session = await getSession(context) + const id = context.query.id?.toString() + + return { + props: { + id: Utils.lns(id), + isUser: session?.user ? true : false, + isSponsor: session?.isSponsor ?? false, + }, + } +} diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts index dd55f788e..bcde2ddff 100644 --- a/packages/core/src/utils/utils.ts +++ b/packages/core/src/utils/utils.ts @@ -1485,6 +1485,21 @@ left past the initial left edge) then swap points on that axis. static metaKey(e: KeyboardEvent | React.KeyboardEvent): boolean { return Utils.isDarwin() ? e.metaKey : e.ctrlKey } + + /** + * Reversable psuedo hash. + * @param str string + */ + static lns(str: string) { + const result = str + .split('') + .map((n) => (Number.isNaN(+n) ? n : +n < 5 ? 5 + +n : +n > 5 ? +n - 5 : +n)) + result.push(...result.splice(0, Math.round(result.length / 5))) + result.push(...result.splice(0, Math.round(result.length / 4))) + result.push(...result.splice(0, Math.round(result.length / 3))) + result.push(...result.splice(0, Math.round(result.length / 2))) + return result.reverse().join('') + } } export default Utils diff --git a/packages/tldraw/src/components/TopPanel/MultiplayerMenu/MultiplayerMenu.tsx b/packages/tldraw/src/components/TopPanel/MultiplayerMenu/MultiplayerMenu.tsx index 63bdd9844..31e34b45d 100644 --- a/packages/tldraw/src/components/TopPanel/MultiplayerMenu/MultiplayerMenu.tsx +++ b/packages/tldraw/src/components/TopPanel/MultiplayerMenu/MultiplayerMenu.tsx @@ -19,10 +19,23 @@ export const MultiplayerMenu = React.memo(function MultiplayerMenu() { const [copied, setCopied] = React.useState(false) + const rTimeout = React.useRef(0) + const handleCopySelect = React.useCallback(() => { setCopied(true) TLDR.copyStringToClipboard(window.location.href) - setTimeout(() => setCopied(false), 1200) + clearTimeout(rTimeout.current) + rTimeout.current = setTimeout(() => setCopied(false), 1200) + }, []) + + const handleCopyReadOnlySelect = React.useCallback(() => { + setCopied(true) + const segs = window.location.href.split('/') + segs[segs.length - 2] = 'v' + segs[segs.length - 1] = Utils.lns(segs[segs.length - 1]) + TLDR.copyStringToClipboard(segs.join('/')) + clearTimeout(rTimeout.current) + rTimeout.current = setTimeout(() => setCopied(false), 1200) }, []) const handleCreateMultiplayerProject = React.useCallback(async () => { @@ -103,6 +116,14 @@ export const MultiplayerMenu = React.memo(function MultiplayerMenu() { {copied ? : } + + + {copied ? : } + {(showStyles || showZoom) && ( - - - - - - + {app.readOnly ? ( + Read Only + ) : ( + <> + {' '} + + + + + + + + )} {showZoom && } {showStyles && !readOnly && } @@ -76,3 +82,15 @@ const StyledSpacer = styled('div', { flexGrow: 2, pointerEvents: 'none', }) + +const ReadOnlyLabel = styled('div', { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontFamily: '$ui', + fontSize: '$1', + paddingLeft: '$4', + paddingRight: '$1', + userSelect: 'none', +}) diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index 0ef52fd28..59723126e 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -1230,8 +1230,7 @@ export class TldrawApp extends StateManager { const doc = TldrawApp.defaultDocument // Set the default page name to the localized version of "Page" - const translation = getTranslation(this.settings.language) - doc.pages['page'].name = translation.messages['page'] + ' 1' ?? 'Page 1' + doc.pages['page'].name = 'Page 1' this.resetHistory() .clearSelectHistory() diff --git a/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts b/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts index e492d710e..4675bfb1b 100644 --- a/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts +++ b/packages/tldraw/src/state/tools/SelectTool/SelectTool.ts @@ -244,9 +244,20 @@ export class SelectTool extends BaseTool { // Pointer Events (generic) onPointerMove: TLPointerEventHandler = () => { - if (this.app.readOnly) return const { originPoint, currentPoint } = this.app + if (this.app.readOnly && this.app.isPointing) { + if (this.app.session) { + this.app.updateSession() + } else { + if (Vec.dist(originPoint, currentPoint) > DEAD_ZONE) { + this.app.startSession(SessionType.Brush) + this.setStatus(Status.Brushing) + } + } + return + } + switch (this.status) { case Status.PointingBoundsHandle: { if (!this.pointedBoundsHandle) throw Error('No pointed bounds handle') diff --git a/packages/tldraw/src/translations/ar.json b/packages/tldraw/src/translations/ar.json index 1a2387c22..b26e96829 100644 --- a/packages/tldraw/src/translations/ar.json +++ b/packages/tldraw/src/translations/ar.json @@ -87,6 +87,7 @@ "backward": "للوراء", "back": "خلف", "language": "لغة", + "translation.link": "للمزيد", "dock.position": "موقع الادوات", "bottom": "اسفل", "left": "يسار", diff --git a/packages/tldraw/src/translations/main.json b/packages/tldraw/src/translations/main.json index dba9b8c1f..35e06b4df 100644 --- a/packages/tldraw/src/translations/main.json +++ b/packages/tldraw/src/translations/main.json @@ -3,15 +3,15 @@ "style.menu.fill": "Fill", "style.menu.dash": "Dash", "style.menu.size": "Size", - "style.menu.keep.open": "Keep open", + "style.menu.keep.open": "Keep Open", "style.menu.font": "Font", "style.menu.align": "Align", "styles": "Styles", - "zoom.in": "Zoom in", - "zoom.out": "Zoom out", - "to": "to", - "to.selection": "To selection", - "to.fit": "To fit", + "zoom.in": "Zoom In", + "zoom.out": "Zoom Out", + "to": "To", + "to.selection": "To Selection", + "to.fit": "To Fit", "menu.file": "File", "menu.edit": "Edit", "menu.view": "View", @@ -19,7 +19,7 @@ "menu.sign.in": "Sign In", "menu.sign.out": "Sign Out", "sponsored": "Sponsored", - "become.a.sponsor": "Become a sponsor", + "become.a.sponsor": "Become a Sponsor", "zoom.to.selection": "Zoom to Selection", "zoom.to.fit": "Zoom to Fit", "zoom.to": "Zoom to", @@ -38,10 +38,10 @@ "cut": "Cut", "copy": "Copy", "paste": "Paste", - "copy.as": "Copy as", - "export.as": "Export as", - "select.all": "Select all", - "select.none": "Select none", + "copy.as": "Copy As", + "export.as": "Export As", + "select.all": "Select All", + "select.none": "Select None", "delete": "Delete", "new.project": "New Project", "open": "Open", @@ -54,6 +54,7 @@ "duplicate": "Duplicate", "cancel": "Cancel", "copy.invite.link": "Copy Invite Link", + "copy.readonly.link": "Copy ReadOnly Link", "create.multiplayer.project": "Create a Multiplayer Project", "copy.multiplayer.project": "Copy to Multiplayer Project", "select": "Select", @@ -85,7 +86,7 @@ "to.front": "To Front", "forward": "Forward", "backward": "Backward", - "back": "Back", + "back": "To Back", "language": "Language", "translation.link": "Learn More", "dock.position": "Dock Position", diff --git a/packages/tldraw/src/translations/translations.ts b/packages/tldraw/src/translations/translations.ts index 7fdc119fd..1bcd67744 100644 --- a/packages/tldraw/src/translations/translations.ts +++ b/packages/tldraw/src/translations/translations.ts @@ -53,7 +53,7 @@ TRANSLATIONS.sort((a, b) => (a.locale < b.locale ? -1 : 1)) export type TDTranslation = { readonly locale: string readonly label: string - readonly messages: Partial + readonly messages: Partial } export type TDTranslations = TDTranslation[]