Compare commits

..
Author SHA1 Message Date
zsviczianandGitHub a17090f455 points cannot be null 2025-08-07 21:39:50 +02:00
zsviczianandGitHub 2df323a5c3 Apply fixed delta only if points.length > 1 2025-08-07 21:28:30 +02:00
Marcel MrazandGitHub df25de7e68 feat: fix delta apply to issues (#9830) 2025-08-07 15:38:58 +02:00
David LuzarandGitHub a3763648fe chore: update title (#9814)
* chore: update title

* update meta tag

* lint
2025-08-01 17:17:42 +02:00
178eca5828 fix: add frame clipping to new element canvas (#9794)
* fix: add frame clipping to new element canvas

* cleanup save/restore

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-31 12:10:59 +00:00
Ryan DiandGitHub cb33de25f4 feat: allow a frame to snap to its children (#9795) 2025-07-31 13:58:29 +02:00
14 changed files with 248 additions and 309 deletions
+3 -18
View File
@@ -112,9 +112,7 @@ import {
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { import {
importFromLocalStorage, importFromLocalStorage,
importFromIndexedDB,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
migrateFromLocalStorageToIndexedDB,
} from "./data/localStorage"; } from "./data/localStorage";
import { loadFilesFromFirebase } from "./data/firebase"; import { loadFilesFromFirebase } from "./data/firebase";
@@ -220,15 +218,7 @@ const initializeScene = async (opts: {
); );
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
// migrate from localStorage to IndexedDB if needed const localDataState = importFromLocalStorage();
await migrateFromLocalStorageToIndexedDB();
// try to load from IndexedDB first, fallback to localStorage
let localDataState = await importFromIndexedDB();
if (!localDataState.elements.length && !localDataState.appState) {
// fallback to localStorage if IndexedDB is empty
localDataState = importFromLocalStorage();
}
let scene: RestoredDataState & { let scene: RestoredDataState & {
scrollToContent?: boolean; scrollToContent?: boolean;
@@ -514,7 +504,7 @@ const ExcalidrawWrapper = () => {
TITLE_TIMEOUT, TITLE_TIMEOUT,
); );
const syncData = debounce(async () => { const syncData = debounce(() => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
} }
@@ -524,12 +514,7 @@ const ExcalidrawWrapper = () => {
) { ) {
// don't sync if local state is newer or identical to browser state // don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
// try to load from IndexedDB first, fallback to localStorage const localDataState = importFromLocalStorage();
let localDataState = await importFromIndexedDB();
if (!localDataState.elements.length && !localDataState.appState) {
// fallback to localStorage if IndexedDB is empty
localDataState = importFromLocalStorage();
}
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
+5 -17
View File
@@ -21,23 +21,11 @@ type StorageSizes = { scene: number; total: number };
const STORAGE_SIZE_TIMEOUT = 500; const STORAGE_SIZE_TIMEOUT = 500;
const getStorageSizes = debounce(async (cb: (sizes: StorageSizes) => void) => { const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
try { cb({
const [scene, total] = await Promise.all([ scene: getElementsStorageSize(),
getElementsStorageSize(), total: getTotalStorageSize(),
getTotalStorageSize(), });
]);
cb({
scene,
total,
});
} catch (error) {
console.error("Failed to get storage sizes:", error);
cb({
scene: 0,
total: 0,
});
}
}, STORAGE_SIZE_TIMEOUT); }, STORAGE_SIZE_TIMEOUT);
type Props = { type Props = {
+11 -69
View File
@@ -65,7 +65,7 @@ class LocalFileManager extends FileManager {
}; };
} }
const saveDataStateToIndexedDB = async ( const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
@@ -79,15 +79,17 @@ const saveDataStateToIndexedDB = async (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
// save to IndexedDB localStorage.setItem(
await Promise.all([ STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
ElementsIndexedDBAdapter.save(clearElementsForLocalStorage(elements)), JSON.stringify(clearElementsForLocalStorage(elements)),
AppStateIndexedDBAdapter.save(_appState), );
]); localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) { } catch (error: any) {
// unable to access IndexedDB // Unable to access window.localStorage
console.error(error); console.error(error);
} }
}; };
@@ -102,7 +104,7 @@ export class LocalData {
files: BinaryFiles, files: BinaryFiles,
onFilesSaved: () => void, onFilesSaved: () => void,
) => { ) => {
await saveDataStateToIndexedDB(elements, appState); saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({ await this.fileStorage.saveFiles({
elements, elements,
@@ -254,63 +256,3 @@ export class LibraryLocalStorageMigrationAdapter {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
} }
} }
/** IndexedDB Adapter for storing app state */
export class AppStateIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = "excalidraw-app-state";
/** app state data store key */
private static key = "appStateData";
private static store = createStore(
`${AppStateIndexedDBAdapter.idb_name}-db`,
`${AppStateIndexedDBAdapter.idb_name}-store`,
);
static async load() {
const IDBData = await get<Partial<AppState>>(
AppStateIndexedDBAdapter.key,
AppStateIndexedDBAdapter.store,
);
return IDBData || null;
}
static save(data: Partial<AppState>): MaybePromise<void> {
return set(
AppStateIndexedDBAdapter.key,
data,
AppStateIndexedDBAdapter.store,
);
}
}
/** IndexedDB Adapter for storing elements */
export class ElementsIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = "excalidraw-elements";
/** elements data store key */
private static key = "elementsData";
private static store = createStore(
`${ElementsIndexedDBAdapter.idb_name}-db`,
`${ElementsIndexedDBAdapter.idb_name}-store`,
);
static async load() {
const IDBData = await get<ExcalidrawElement[]>(
ElementsIndexedDBAdapter.key,
ElementsIndexedDBAdapter.store,
);
return IDBData || null;
}
static save(data: ExcalidrawElement[]): MaybePromise<void> {
return set(
ElementsIndexedDBAdapter.key,
data,
ElementsIndexedDBAdapter.store,
);
}
}
+10 -133
View File
@@ -9,11 +9,6 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import {
AppStateIndexedDBAdapter,
ElementsIndexedDBAdapter,
} from "./LocalData";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
try { try {
localStorage.setItem( localStorage.setItem(
@@ -79,146 +74,28 @@ export const importFromLocalStorage = () => {
return { elements, appState }; return { elements, appState };
}; };
export const importFromIndexedDB = async () => { export const getElementsStorageSize = () => {
let savedElements = null;
let savedState = null;
try { try {
savedElements = await ElementsIndexedDBAdapter.load(); const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
savedState = await AppStateIndexedDBAdapter.load(); const elementsSize = elements?.length || 0;
return elementsSize;
} catch (error: any) { } catch (error: any) {
// unable to access IndexedDB
console.error(error); console.error(error);
}
let elements: ExcalidrawElement[] = [];
if (savedElements) {
try {
elements = clearElementsForLocalStorage(savedElements);
} catch (error: any) {
console.error(error);
}
}
let appState = null;
if (savedState) {
try {
appState = {
...getDefaultAppState(),
...clearAppStateForLocalStorage(savedState),
};
} catch (error: any) {
console.error(error);
}
}
return { elements, appState };
};
export const migrateFromLocalStorageToIndexedDB = async () => {
try {
// check if we have data in localStorage
const savedElements = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
);
const savedState = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
);
if (savedElements || savedState) {
// parse and migrate elements
if (savedElements) {
try {
const elements = JSON.parse(savedElements);
await ElementsIndexedDBAdapter.save(elements);
} catch (error) {
console.error("Failed to migrate elements:", error);
}
}
// parse and migrate app state
if (savedState) {
try {
const appState = JSON.parse(savedState);
await AppStateIndexedDBAdapter.save(appState);
} catch (error) {
console.error("Failed to migrate app state:", error);
}
}
// clear localStorage after successful migration
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
}
} catch (error) {
console.error("Migration failed:", error);
}
};
/**
* Get the size of elements stored in IndexedDB (with localStorage fallback)
* @returns Promise<number> - Size in bytes
*/
export const getElementsStorageSize = async () => {
try {
const elements = await ElementsIndexedDBAdapter.load();
if (elements) {
// calculate size by stringifying the data
const elementsString = JSON.stringify(elements);
return elementsString.length;
}
return 0; return 0;
} catch (error: any) {
console.error("Failed to get elements size from IndexedDB:", error);
// fallback to localStorage
try {
const elements = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
);
return elements?.length || 0;
} catch (localStorageError: any) {
console.error(
"Failed to get elements size from localStorage:",
localStorageError,
);
return 0;
}
} }
}; };
/** export const getTotalStorageSize = () => {
* Get the total size of all data stored in IndexedDB and localStorage
* @returns Promise<number> - Size in bytes
*/
export const getTotalStorageSize = async () => {
try { try {
const appState = await AppStateIndexedDBAdapter.load(); const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const appStateSize = appState ? JSON.stringify(appState).length : 0; const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0; const collabSize = collab?.length || 0;
const elementsSize = await getElementsStorageSize(); return appStateSize + collabSize + getElementsStorageSize();
return appStateSize + collabSize + elementsSize;
} catch (error: any) { } catch (error: any) {
console.error("Failed to get total storage size from IndexedDB:", error); console.error(error);
// fallback to localStorage return 0;
try {
const appState = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0;
const elementsSize = await getElementsStorageSize();
return appStateSize + collabSize + elementsSize;
} catch (localStorageError: any) {
console.error(
"Failed to get total storage size from localStorage:",
localStorageError,
);
return 0;
}
} }
}; };
+4 -2
View File
@@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title> <title>
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
</title>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@@ -14,7 +16,7 @@
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<meta <meta
name="title" name="title"
content="Excalidraw — Collaborative whiteboarding made easy" content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
/> />
<meta <meta
name="description" name="description"
+63 -20
View File
@@ -151,6 +151,16 @@ export class Delta<T> {
); );
} }
/**
* Merges two deltas into a new one.
*/
public static merge<T>(delta1: Delta<T>, delta2: Delta<T>) {
return Delta.create(
{ ...delta1.deleted, ...delta2.deleted },
{ ...delta1.inserted, ...delta2.inserted },
);
}
/** /**
* Merges deleted and inserted object partials. * Merges deleted and inserted object partials.
*/ */
@@ -497,6 +507,11 @@ export interface DeltaContainer<T> {
*/ */
applyTo(previous: T, ...options: unknown[]): [T, boolean]; applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Squashes the current delta with the given one.
*/
squash(delta: DeltaContainer<T>): this;
/** /**
* Checks whether all `Delta`s are empty. * Checks whether all `Delta`s are empty.
*/ */
@@ -504,7 +519,7 @@ export interface DeltaContainer<T> {
} }
export class AppStateDelta implements DeltaContainer<AppState> { export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {} private constructor(public delta: Delta<ObservedAppState>) {}
public static calculate<T extends ObservedAppState>( public static calculate<T extends ObservedAppState>(
prevAppState: T, prevAppState: T,
@@ -535,6 +550,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return new AppStateDelta(inversedDelta); return new AppStateDelta(inversedDelta);
} }
public squash(delta: AppStateDelta): this {
this.delta = Delta.merge(this.delta, delta.delta);
return this;
}
public applyTo( public applyTo(
appState: AppState, appState: AppState,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
@@ -1196,8 +1216,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => { const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {}; const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) { for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
} }
return inversedDeltas; return inversedDeltas;
@@ -1395,6 +1415,42 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
} }
public squash(delta: ElementsDelta): this {
const { added, removed, updated } = delta;
for (const [id, nextDelta] of Object.entries(added)) {
const prevDelta = this.added[id];
if (!prevDelta) {
this.added[id] = nextDelta;
} else {
this.added[id] = Delta.merge(prevDelta, nextDelta);
}
}
for (const [id, nextDelta] of Object.entries(removed)) {
const prevDelta = this.removed[id];
if (!prevDelta) {
this.removed[id] = nextDelta;
} else {
this.removed[id] = Delta.merge(prevDelta, nextDelta);
}
}
for (const [id, nextDelta] of Object.entries(updated)) {
const prevDelta = this.updated[id];
if (!prevDelta) {
this.updated[id] = nextDelta;
} else {
this.updated[id] = Delta.merge(prevDelta, nextDelta);
}
}
return this;
}
private static createApplier = private static createApplier =
( (
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
@@ -1624,25 +1680,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
); );
// calculate complete deltas for affected elements, and assign them back to all the deltas // calculate complete deltas for affected elements, and squash them back to the current deltas
// technically we could do better here if perf. would become an issue this.squash(
const { added, removed, updated } = ElementsDelta.calculate( // technically we could do better here if perf. would become an issue
prevAffectedElements, ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
nextAffectedElements,
); );
for (const [id, delta] of Object.entries(added)) {
this.added[id] = delta;
}
for (const [id, delta] of Object.entries(removed)) {
this.removed[id] = delta;
}
for (const [id, delta] of Object.entries(updated)) {
this.updated[id] = delta;
}
return nextAffectedElements; return nextAffectedElements;
} }
+2 -2
View File
@@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events. * Store which captures the observed changes and emits them as `StoreIncrement` events.
*/ */
export class Store { export class Store {
// internally used by history // for internal use by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter< public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement] [DurableIncrement | EphemeralIncrement]
>(); >();
@@ -239,7 +240,6 @@ export class Store {
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta); const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment); this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment);
} }
+1 -1
View File
@@ -9107,7 +9107,7 @@ class App extends React.Component<AppProps, AppState> {
newElement && newElement &&
!multiElement !multiElement
) { ) {
if (this.device.isTouchScreen) { if (this.device.isTouchScreen && newElement!.points.length > 1) {
const FIXED_DELTA_X = Math.min( const FIXED_DELTA_X = Math.min(
(this.state.width * 0.7) / this.state.zoom.value, (this.state.width * 0.7) / this.state.zoom.value,
100, 100,
+7 -29
View File
@@ -4,13 +4,7 @@ import {
supported as nativeFileSystemSupported, supported as nativeFileSystemSupported,
} from "browser-fs-access"; } from "browser-fs-access";
import { import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
EVENT,
MIME_TYPES,
debounce,
isIOS,
isAndroid,
} from "@excalidraw/common";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
@@ -19,8 +13,6 @@ import type { FileSystemHandle } from "browser-fs-access";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500; const INPUT_CHANGE_INTERVAL_MS = 500;
// increase timeout for mobile devices to give more time for file selection
const MOBILE_INPUT_CHANGE_INTERVAL_MS = 2000;
export const fileOpen = <M extends boolean | undefined = false>(opts: { export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
@@ -49,22 +41,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => { legacySetup: (resolve, reject, input) => {
const isMobile = isIOS || isAndroid; const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const intervalMs = isMobile
? MOBILE_INPUT_CHANGE_INTERVAL_MS
: INPUT_CHANGE_INTERVAL_MS;
const scheduleRejection = debounce(reject, intervalMs);
const focusHandler = () => { const focusHandler = () => {
checkForFile(); checkForFile();
// on mobile, be less aggressive with rejection document.addEventListener(EVENT.KEYUP, scheduleRejection);
if (!isMobile) { document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
document.addEventListener(EVENT.KEYUP, scheduleRejection); scheduleRejection();
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
}
}; };
const checkForFile = () => { const checkForFile = () => {
// this hack might not work when expecting multiple files // this hack might not work when expecting multiple files
if (input.files?.length) { if (input.files?.length) {
@@ -72,15 +55,12 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
resolve(ret as RetType); resolve(ret as RetType);
} }
}; };
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler); window.addEventListener(EVENT.FOCUS, focusHandler);
}); });
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
checkForFile(); checkForFile();
}, intervalMs); }, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => { return (rejectPromise) => {
clearInterval(interval); clearInterval(interval);
scheduleRejection.cancel(); scheduleRejection.cancel();
@@ -89,9 +69,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection); document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) { if (rejectPromise) {
// so that something is shown in console if we need to debug this // so that something is shown in console if we need to debug this
console.warn( console.warn("Opening the file was canceled (legacy-fs).");
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
);
rejectPromise(new AbortError()); rejectPromise(new AbortError());
} }
}; };
@@ -1,9 +1,16 @@
import { throttleRAF } from "@excalidraw/common"; import { throttleRAF } from "@excalidraw/common";
import { isInvisiblySmallElement, renderElement } from "@excalidraw/element"; import {
getTargetFrame,
isInvisiblySmallElement,
renderElement,
shouldApplyFrameClip,
} from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { frameClip } from "./staticScene";
import type { NewElementSceneRenderConfig } from "../scene/types"; import type { NewElementSceneRenderConfig } from "../scene/types";
const _renderNewElementScene = ({ const _renderNewElementScene = ({
@@ -29,8 +36,9 @@ const _renderNewElementScene = ({
normalizedHeight, normalizedHeight,
}); });
// Apply zoom
context.save(); context.save();
// Apply zoom
context.scale(appState.zoom.value, appState.zoom.value); context.scale(appState.zoom.value, appState.zoom.value);
if (newElement && newElement.type !== "selection") { if (newElement && newElement.type !== "selection") {
@@ -42,6 +50,23 @@ const _renderNewElementScene = ({
return; return;
} }
const frameId = newElement.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
const frame = getTargetFrame(newElement, elementsMap, appState);
if (
frame &&
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
) {
frameClip(frame, context, renderConfig, appState);
}
}
renderElement( renderElement(
newElement, newElement,
elementsMap, elementsMap,
@@ -54,6 +79,8 @@ const _renderNewElementScene = ({
} else { } else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight); context.clearRect(0, 0, normalizedWidth, normalizedHeight);
} }
context.restore();
} }
}; };
+1 -1
View File
@@ -113,7 +113,7 @@ const strokeGrid = (
context.restore(); context.restore();
}; };
const frameClip = ( export const frameClip = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
+3 -10
View File
@@ -13,7 +13,7 @@ import {
getDraggedElementsBounds, getDraggedElementsBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element"; import { isBoundToContainer } from "@excalidraw/element";
import { getMaximumGroups } from "@excalidraw/element"; import { getMaximumGroups } from "@excalidraw/element";
@@ -311,20 +311,13 @@ const getReferenceElements = (
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) =>
const selectedFrames = selectedElements getVisibleAndNonSelectedElements(
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id);
return getVisibleAndNonSelectedElements(
elements, elements,
selectedElements, selectedElements,
appState, appState,
elementsMap, elementsMap,
).filter(
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
); );
};
export const getVisibleGaps = ( export const getVisibleGaps = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -2924,7 +2924,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 11,
"width": 100, "width": 100,
"x": 10, "x": 10,
"y": 10, "y": 10,
@@ -3001,7 +3001,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"textAlign": "left", "textAlign": "left",
"type": "text", "type": "text",
"updated": 1, "updated": 1,
"version": 5, "version": 11,
"verticalAlign": "top", "verticalAlign": "top",
"width": 30, "width": 30,
"x": 15, "x": 15,
@@ -3031,14 +3031,67 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"version": 9, "version": 9,
}, },
"inserted": { "inserted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": null, "containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
"height": 100,
"index": "a0",
"isDeleted": false, "isDeleted": false,
"lineHeight": "1.25000",
"link": null,
"locked": false,
"opacity": 100,
"originalText": "que pasa",
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "que pasa",
"textAlign": "left",
"type": "text",
"version": 8, "version": 8,
"verticalAlign": "top",
"width": 100,
"x": 15,
"y": 15,
}, },
}, },
}, },
"removed": {}, "removed": {},
"updated": {}, "updated": {
"id0": {
"deleted": {
"boundElements": [],
"version": 11,
},
"inserted": {
"boundElements": [
{
"id": "id1",
"type": "text",
},
],
"version": 10,
},
},
"id5": {
"deleted": {
"version": 11,
},
"inserted": {
"version": 9,
},
},
},
}, },
"id": "id9", "id": "id9",
}, },
@@ -5036,9 +5089,29 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"removed": { "removed": {
"id0": { "id0": {
"deleted": { "deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [], "boundElements": [],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8, "version": 8,
"width": 100,
"x": 10,
"y": 10,
}, },
"inserted": { "inserted": {
"boundElements": [ "boundElements": [
@@ -5266,9 +5339,38 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"removed": { "removed": {
"id1": { "id1": {
"deleted": { "deleted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": null, "containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
"height": 100,
"index": "a0",
"isDeleted": false, "isDeleted": false,
"lineHeight": "1.25000",
"link": null,
"locked": false,
"opacity": 100,
"originalText": "que pasa",
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "que pasa",
"textAlign": "left",
"type": "text",
"version": 8, "version": 8,
"verticalAlign": "top",
"width": 100,
"x": 15,
"y": 15,
}, },
"inserted": { "inserted": {
"containerId": "id0", "containerId": "id0",
@@ -5525,9 +5627,11 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"updated": { "updated": {
"id1": { "id1": {
"deleted": { "deleted": {
"frameId": null,
"version": 10, "version": 10,
}, },
"inserted": { "inserted": {
"frameId": null,
"version": 8, "version": 8,
}, },
}, },
+2 -2
View File
@@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
}); });
rl.question( rl.question(
"Do you want to commit these changes to git? (Y/n): ", "Would you like to commit these changes to git? (Y/n): ",
(answer) => { (answer) => {
rl.close(); rl.close();
@@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
}); });
rl.question( rl.question(
"Do you want to publish these changes to npm? (Y/n): ", "Would you like to publish these changes to npm? (Y/n): ",
(answer) => { (answer) => {
rl.close(); rl.close();