diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index bb62a0e96..c6e05e286 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -4,6 +4,7 @@ import { TTDDialogTrigger, CaptureUpdateAction, reconcileElements, + getCommonBounds, } from "@excalidraw/excalidraw"; import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; @@ -60,6 +61,7 @@ import { import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore"; import type { + ExcalidrawElement, FileId, NonDeletedExcalidrawElement, OrderedExcalidrawElement, @@ -70,8 +72,9 @@ import type { BinaryFiles, ExcalidrawInitialDataState, UIAppState, + ScrollConstraints, } from "@excalidraw/excalidraw/types"; -import type { ResolutionType } from "@excalidraw/common/utility-types"; +import type { Merge, ResolutionType } from "@excalidraw/common/utility-types"; import type { ResolvablePromise } from "@excalidraw/common/utils"; import CustomStats from "./CustomStats"; @@ -138,9 +141,156 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; import "./index.scss"; import type { CollabAPI } from "./collab/Collab"; +import { getSelectedElements } from "@excalidraw/element/selection"; polyfill(); +type DebugScrollConstraints = Merge< + ScrollConstraints, + { viewportZoomFactor: number; enabled: boolean } +>; + +const ConstraintsSettings = ({ + initialConstraints, + excalidrawAPI, +}: { + initialConstraints: DebugScrollConstraints; + excalidrawAPI: ExcalidrawImperativeAPI; +}) => { + const [constraints, setConstraints] = + useState(initialConstraints); + + useEffect(() => { + // add JSON-stringified constraints into url hash for easy sharing + const hash = new URLSearchParams(window.location.hash.slice(1)); + hash.set( + "constraints", + encodeURIComponent( + window.btoa(JSON.stringify(constraints)).replace(/=+/, ""), + ), + ); + window.location.hash = decodeURIComponent(hash.toString()); + excalidrawAPI.setScrollConstraints(constraints); + }, [constraints]); + + const [selection, setSelection] = useState([]); + useEffect(() => { + return excalidrawAPI.onChange((elements, appState) => { + setSelection(getSelectedElements(elements, appState)); + }); + }, [excalidrawAPI]); + + return ( +
+ enabled:{" "} + + setConstraints((s) => ({ ...s, enabled: e.target.checked })) + } + /> + x:{" "} + + setConstraints((s) => ({ + ...s, + x: parseInt(e.target.value) ?? 0, + })) + } + /> + y:{" "} + + setConstraints((s) => ({ + ...s, + y: parseInt(e.target.value) ?? 0, + })) + } + /> + w:{" "} + + setConstraints((s) => ({ + ...s, + width: parseInt(e.target.value) ?? 200, + })) + } + /> + h:{" "} + + setConstraints((s) => ({ + ...s, + height: parseInt(e.target.value) ?? 200, + })) + } + /> + zoomFactor: + + setConstraints((s) => ({ + ...s, + viewportZoomFactor: parseFloat(e.target.value.toString()) ?? 0.7, + })) + } + /> + lockZoom:{" "} + + setConstraints((s) => ({ ...s, lockZoom: e.target.checked })) + } + /> + {selection.length > 0 && ( + + )} +
+ ); +}; + window.EXCALIDRAW_THROTTLE_RENDER = true; declare global { @@ -212,10 +362,22 @@ const initializeScene = async (opts: { ) > => { const searchParams = new URLSearchParams(window.location.search); + const hashParams = new URLSearchParams(window.location.hash.slice(1)); const id = searchParams.get("id"); - const jsonBackendMatch = window.location.hash.match( - /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/, - ); + const shareableLink = hashParams.get("json")?.split(","); + + if (shareableLink) { + hashParams.delete("json"); + const hash = `#${decodeURIComponent(hashParams.toString())}`; + window.history.replaceState( + {}, + APP_NAME, + `${window.location.origin}${hash}`, + ); + } + + console.log({ shareableLink }); + const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); const localDataState = importFromLocalStorage(); @@ -225,7 +387,7 @@ const initializeScene = async (opts: { } = await loadScene(null, null, localDataState); let roomLinkData = getCollaborationLinkData(window.location.href); - const isExternalScene = !!(id || jsonBackendMatch || roomLinkData); + const isExternalScene = !!(id || shareableLink || roomLinkData); if (isExternalScene) { if ( // don't prompt if scene is empty @@ -235,16 +397,17 @@ const initializeScene = async (opts: { // otherwise, prompt whether user wants to override current scene (await openConfirmModal(shareableLinkConfirmDialog)) ) { - if (jsonBackendMatch) { + if (shareableLink) { scene = await loadScene( - jsonBackendMatch[1], - jsonBackendMatch[2], + shareableLink[0], + shareableLink[1], localDataState, ); + console.log(">>>>", scene); } scene.scrollToContent = true; if (!roomLinkData) { - window.history.replaceState({}, APP_NAME, window.location.origin); + // window.history.replaceState({}, APP_NAME, window.location.origin); } } else { // https://github.com/excalidraw/excalidraw/issues/1919 @@ -261,7 +424,7 @@ const initializeScene = async (opts: { } roomLinkData = null; - window.history.replaceState({}, APP_NAME, window.location.origin); + // window.history.replaceState({}, APP_NAME, window.location.origin); } } else if (externalUrlMatch) { window.history.replaceState({}, APP_NAME, window.location.origin); @@ -322,12 +485,12 @@ const initializeScene = async (opts: { key: roomLinkData.roomKey, }; } else if (scene) { - return isExternalScene && jsonBackendMatch + return isExternalScene && shareableLink ? { scene, isExternalScene, - id: jsonBackendMatch[1], - key: jsonBackendMatch[2], + id: shareableLink[0], + key: shareableLink[1], } : { scene, isExternalScene: false }; } @@ -739,6 +902,31 @@ const ExcalidrawWrapper = () => { [setShareDialogState], ); + const [constraints] = useState(() => { + const stored = new URLSearchParams(location.hash.slice(1)).get( + "constraints", + ); + let storedConstraints = {}; + if (stored) { + try { + storedConstraints = JSON.parse(window.atob(stored)); + } catch {} + } + + return { + x: 0, + y: 0, + width: document.body.clientWidth, + height: document.body.clientHeight, + lockZoom: false, + viewportZoomFactor: 0.7, + enabled: true, + ...storedConstraints, + }; + }); + + console.log(constraints); + // browsers generally prevent infinite self-embedding, there are // cases where it still happens, and while we disallow self-embedding // by not whitelisting our own origin, this serves as an additional guard @@ -864,6 +1052,7 @@ const ExcalidrawWrapper = () => { ); }} + scrollConstraints={constraints.enabled ? constraints : undefined} onLinkOpen={(element, event) => { if (element.link && isElementLink(element.link)) { event.preventDefault(); @@ -871,6 +1060,12 @@ const ExcalidrawWrapper = () => { } }} > + {excalidrawAPI && ( + + )} { return { - appState: { + appState: constrainScrollState({ ...appState, ...getStateForZoom( { @@ -147,7 +148,7 @@ export const actionZoomIn = register({ appState, ), userToFollow: null, - }, + }), captureUpdate: CaptureUpdateAction.EVENTUALLY, }; }, @@ -177,7 +178,7 @@ export const actionZoomOut = register({ trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { return { - appState: { + appState: constrainScrollState({ ...appState, ...getStateForZoom( { @@ -188,7 +189,7 @@ export const actionZoomOut = register({ appState, ), userToFollow: null, - }, + }), captureUpdate: CaptureUpdateAction.EVENTUALLY, }; }, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index a75745f2a..c2ae76964 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -20,7 +20,7 @@ const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) export const getDefaultAppState = (): Omit< AppState, - "offsetTop" | "offsetLeft" | "width" | "height" + "offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints" > => { return { showWelcomeScreen: false, @@ -243,6 +243,7 @@ const APP_STATE_STORAGE_CONF = (< objectsSnapModeEnabled: { browser: true, export: false, server: false }, userToFollow: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false }, + scrollConstraints: { browser: false, export: false, server: false }, isCropping: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ddb071981..032b2e3ce 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -522,6 +522,8 @@ import type { FrameNameBoundsCache, SidebarName, SidebarTabName, + ScrollConstraints, + AnimateTranslateCanvasValues, KeyboardModifiersObject, CollaboratorPointer, ToolType, @@ -535,6 +537,10 @@ import type { } from "../types"; import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Action, ActionResult } from "../actions/types"; +import { + constrainScrollState, + calculateConstrainedScrollCenter, +} from "../scene/scrollConstraints"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -570,6 +576,7 @@ const ExcalidrawAppStateContext = React.createContext({ height: 0, offsetLeft: 0, offsetTop: 0, + scrollConstraints: null, }); ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext"; @@ -607,6 +614,8 @@ let isDraggingScrollBar: boolean = false; let currentScrollBars: ScrollBars = { horizontal: null, vertical: null }; let touchTimeout = 0; let invalidateContextMenu = false; +let scrollConstraintsAnimationTimeout: ReturnType | null = + null; /** * Map of youtube embed video states @@ -731,7 +740,9 @@ class App extends React.Component { objectsSnapModeEnabled = false, theme = defaultAppState.theme, name = `${t("labels.untitled")}-${getDateTime()}`, + scrollConstraints, } = props; + this.state = { ...defaultAppState, theme, @@ -744,6 +755,7 @@ class App extends React.Component { name, width: window.innerWidth, height: window.innerHeight, + scrollConstraints: scrollConstraints ?? null, }; this.id = nanoid(); @@ -791,6 +803,7 @@ class App extends React.Component { resetCursor: this.resetCursor, updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, + setScrollConstraints: this.setScrollConstraints, onChange: (cb) => this.onChangeEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), @@ -2406,7 +2419,12 @@ class App extends React.Component { isLoading: false, toast: this.state.toast, }; - if (initialData?.scrollToContent) { + if (this.props.scrollConstraints) { + scene.appState = { + ...scene.appState, + ...calculateConstrainedScrollCenter(this.state, scene.appState), + }; + } else if (initialData?.scrollToContent) { scene.appState = { ...scene.appState, ...calculateScrollCenter(scene.elements, { @@ -2415,6 +2433,7 @@ class App extends React.Component { height: this.state.height, offsetTop: this.state.offsetTop, offsetLeft: this.state.offsetLeft, + scrollConstraints: this.state.scrollConstraints, }), }; } @@ -2628,7 +2647,11 @@ class App extends React.Component { if (!supportsResizeObserver) { this.refreshEditorBreakpoints(); } - this.setState({}); + if (this.state.scrollConstraints) { + this.setState((state) => constrainScrollState(state)); + } else { + this.setState({}); + } }); /** generally invoked only if fullscreen was invoked programmatically */ @@ -2959,6 +2982,28 @@ class App extends React.Component { this.props.onChange?.(elements, this.state, this.files); this.onChangeEmitter.trigger(elements, this.state, this.files); } + + if (this.state.scrollConstraints?.animateOnNextUpdate) { + const newState = constrainScrollState(this.state, { + allowOverscroll: false, + }); + + scrollConstraintsAnimationTimeout = setTimeout(() => { + this.cancelInProgressAnimation?.(); + const fromValues = { + scrollX: this.state.scrollX, + scrollY: this.state.scrollY, + zoom: this.state.zoom.value, + }; + const toValues = { + scrollX: newState.scrollX, + scrollY: newState.scrollY, + zoom: newState.zoom.value, + }; + + this.animateToConstrainedArea(fromValues, toValues); + }, 200); + } } private renderInteractiveSceneCallback = ({ @@ -3705,8 +3750,8 @@ class App extends React.Component { */ value: number, ) => { - this.setState({ - ...getStateForZoom( + this.setState( + getStateForZoom( { viewportX: this.state.width / 2 + this.state.offsetLeft, viewportY: this.state.height / 2 + this.state.offsetTop, @@ -3714,7 +3759,7 @@ class App extends React.Component { }, this.state, ), - }); + ); }; private cancelInProgressAnimation: (() => void) | null = null; @@ -3814,32 +3859,18 @@ class App extends React.Component { // when animating, we use RequestAnimationFrame to prevent the animation // from slowing down other processes if (opts?.animate) { - const origScrollX = this.state.scrollX; - const origScrollY = this.state.scrollY; - const origZoom = this.state.zoom.value; + const fromValues = { + scrollX: this.state.scrollX, + scrollY: this.state.scrollY, + zoom: this.state.zoom.value, + }; - const cancel = easeToValuesRAF({ - fromValues: { - scrollX: origScrollX, - scrollY: origScrollY, - zoom: origZoom, - }, - toValues: { scrollX, scrollY, zoom: zoom.value }, - interpolateValue: (from, to, progress, key) => { - // for zoom, use different easing - if (key === "zoom") { - return from * Math.pow(to / from, easeOut(progress)); - } - // handle using default - return undefined; - }, - onStep: ({ scrollX, scrollY, zoom }) => { - this.setState({ - scrollX, - scrollY, - zoom: { value: zoom }, - }); - }, + const toValues = { scrollX, scrollY, zoom: zoom.value }; + + this.animateTranslateCanvas({ + fromValues, + toValues, + duration: opts?.duration ?? 500, onStart: () => { this.setState({ shouldCacheIgnoreZoom: true }); }, @@ -3849,13 +3880,7 @@ class App extends React.Component { onCancel: () => { this.setState({ shouldCacheIgnoreZoom: false }); }, - duration: opts?.duration ?? 500, }); - - this.cancelInProgressAnimation = () => { - cancel(); - this.cancelInProgressAnimation = null; - }; } else { this.setState({ scrollX, scrollY, zoom }); } @@ -3869,11 +3894,114 @@ class App extends React.Component { /** use when changing scrollX/scrollY/zoom based on user interaction */ private translateCanvas: React.Component["setState"] = ( - state, + stateUpdate, ) => { this.cancelInProgressAnimation?.(); this.maybeUnfollowRemoteUser(); - this.setState(state); + + if (scrollConstraintsAnimationTimeout) { + clearTimeout(scrollConstraintsAnimationTimeout); + } + + const partialNewState = + typeof stateUpdate === "function" + ? ( + stateUpdate as ( + prevState: Readonly, + props: Readonly, + ) => AppState + )(this.state, this.props) + : stateUpdate; + + const newState: AppState = { + ...this.state, + ...partialNewState, + ...(this.state.scrollConstraints && { + // manually reset if setState in onCancel wasn't committed yet + shouldCacheIgnoreZoom: false, + }), + }; + + this.setState(constrainScrollState(newState)); + }; + + private animateToConstrainedArea = ( + fromValues: AnimateTranslateCanvasValues, + toValues: AnimateTranslateCanvasValues, + ) => { + const cleanUp = () => { + this.setState((state) => ({ + shouldCacheIgnoreZoom: false, + scrollConstraints: { + ...state.scrollConstraints!, + animateOnNextUpdate: false, + }, + })); + }; + + this.animateTranslateCanvas({ + fromValues, + toValues, + duration: 200, + onStart: () => { + this.setState((state) => { + return { + shouldCacheIgnoreZoom: true, + scrollConstraints: { + ...state.scrollConstraints!, + animateOnNextUpdate: false, + }, + }; + }); + }, + onEnd: cleanUp, + onCancel: cleanUp, + }); + }; + + private animateTranslateCanvas = ({ + fromValues, + toValues, + duration, + onStart, + onEnd, + onCancel, + }: { + fromValues: AnimateTranslateCanvasValues; + toValues: AnimateTranslateCanvasValues; + duration: number; + onStart: () => void; + onEnd: () => void; + onCancel: () => void; + }) => { + const cancel = easeToValuesRAF({ + fromValues, + toValues, + interpolateValue: (from, to, progress, key) => { + // for zoom, use different easing + if (key === "zoom") { + return from * Math.pow(to / from, easeOut(progress)); + } + // handle using default + return undefined; + }, + onStep: ({ scrollX, scrollY, zoom }) => { + this.setState({ + scrollX, + scrollY, + zoom: { value: zoom }, + }); + }, + onStart, + onEnd, + onCancel, + duration, + }); + + this.cancelInProgressAnimation = () => { + cancel(); + this.cancelInProgressAnimation = null; + }; }; setToast = ( @@ -4947,16 +5075,22 @@ class App extends React.Component { const initialScale = gesture.initialScale; if (initialScale) { - this.setState((state) => ({ - ...getStateForZoom( + this.setState((state) => + constrainScrollState( { - viewportX: this.lastViewportPosition.x, - viewportY: this.lastViewportPosition.y, - nextZoom: getNormalizedZoom(initialScale * event.scale), + ...state, + ...getStateForZoom( + { + viewportX: this.lastViewportPosition.x, + viewportY: this.lastViewportPosition.y, + nextZoom: getNormalizedZoom(initialScale * event.scale), + }, + state, + ), }, - state, + { disableAnimation: true }, ), - })); + ); } }); @@ -11269,6 +11403,51 @@ class App extends React.Component { await setLanguage(currentLang); this.setAppState({}); } + + /** + * Sets the scroll constraints of the application state. + * + * @param scrollConstraints - The new scroll constraints. + */ + public setScrollConstraints = ( + scrollConstraints: ScrollConstraints | null, + ) => { + if (scrollConstraints) { + this.setState( + { + scrollConstraints, + viewModeEnabled: true, + }, + () => { + const newState = constrainScrollState( + { + ...this.state, + scrollConstraints, + }, + { allowOverscroll: false }, + ); + + this.animateToConstrainedArea( + { + scrollX: this.state.scrollX, + scrollY: this.state.scrollY, + zoom: this.state.zoom.value, + }, + { + scrollX: newState.scrollX, + scrollY: newState.scrollY, + zoom: newState.zoom.value, + }, + ); + }, + ); + } else { + this.setState({ + scrollConstraints: null, + viewModeEnabled: false, + }); + } + }; } // ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index b2e0d446f..ce31f89bd 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -549,7 +549,7 @@ const LayerUI = ({ showExitZenModeBtn={showExitZenModeBtn} renderWelcomeScreen={renderWelcomeScreen} /> - {appState.scrolledOutside && ( + {appState.scrolledOutside && !appState.scrollConstraints && (