Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e01cd73a7 | |||
| c92ae37f50 | |||
| 071043adae | |||
| 9616e63e23 | |||
| c158187f20 | |||
| 63e1148280 | |||
| b5fc873323 | |||
| 6c908553a9 | |||
| 0586fc138c |
+40
-8
@@ -48,7 +48,11 @@ import {
|
|||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
import { isElementLink } from "@excalidraw/element";
|
import { isElementLink } from "@excalidraw/element";
|
||||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
import {
|
||||||
|
bumpElementVersions,
|
||||||
|
restoreAppState,
|
||||||
|
restoreElements,
|
||||||
|
} from "@excalidraw/excalidraw/data/restore";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -105,8 +109,8 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
|
|||||||
import {
|
import {
|
||||||
exportToBackend,
|
exportToBackend,
|
||||||
getCollaborationLinkData,
|
getCollaborationLinkData,
|
||||||
|
importFromBackend,
|
||||||
isCollaborationLink,
|
isCollaborationLink,
|
||||||
loadScene,
|
|
||||||
} from "./data";
|
} from "./data";
|
||||||
|
|
||||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||||
@@ -224,9 +228,20 @@ const initializeScene = async (opts: {
|
|||||||
|
|
||||||
const localDataState = importFromLocalStorage();
|
const localDataState = importFromLocalStorage();
|
||||||
|
|
||||||
let scene: RestoredDataState & {
|
let scene: Omit<
|
||||||
|
RestoredDataState,
|
||||||
|
// we're not storing files in the scene database/localStorage, and instead
|
||||||
|
// fetch them async from a different store
|
||||||
|
"files"
|
||||||
|
> & {
|
||||||
scrollToContent?: boolean;
|
scrollToContent?: boolean;
|
||||||
} = await loadScene(null, null, localDataState);
|
} = {
|
||||||
|
elements: restoreElements(localDataState?.elements, null, {
|
||||||
|
repairBindings: true,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
}),
|
||||||
|
appState: restoreAppState(localDataState?.appState, null),
|
||||||
|
};
|
||||||
|
|
||||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||||
@@ -240,11 +255,26 @@ const initializeScene = async (opts: {
|
|||||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||||
) {
|
) {
|
||||||
if (jsonBackendMatch) {
|
if (jsonBackendMatch) {
|
||||||
scene = await loadScene(
|
const imported = await importFromBackend(
|
||||||
jsonBackendMatch[1],
|
jsonBackendMatch[1],
|
||||||
jsonBackendMatch[2],
|
jsonBackendMatch[2],
|
||||||
localDataState,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
scene = {
|
||||||
|
elements: bumpElementVersions(
|
||||||
|
restoreElements(imported.elements, null, {
|
||||||
|
repairBindings: true,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
}),
|
||||||
|
localDataState?.elements,
|
||||||
|
),
|
||||||
|
appState: restoreAppState(
|
||||||
|
imported.appState,
|
||||||
|
// local appState when importing from backend to ensure we restore
|
||||||
|
// localStorage user settings which we do not persist on server.
|
||||||
|
localDataState?.appState,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
scene.scrollToContent = true;
|
scene.scrollToContent = true;
|
||||||
if (!roomLinkData) {
|
if (!roomLinkData) {
|
||||||
@@ -496,8 +526,10 @@ const ExcalidrawWrapper = () => {
|
|||||||
loadImages(data);
|
loadImages(data);
|
||||||
if (data.scene) {
|
if (data.scene) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
elements: restoreElements(data.scene.elements, null, {
|
||||||
...restore(data.scene, null, null, { repairBindings: true }),
|
repairBindings: true,
|
||||||
|
}),
|
||||||
|
appState: restoreAppState(data.scene.appState, null),
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
reconcileElements,
|
reconcileElements,
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
||||||
import { APP_NAME, EVENT } from "@excalidraw/common";
|
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
IDLE_THRESHOLD,
|
IDLE_THRESHOLD,
|
||||||
ACTIVE_THRESHOLD,
|
ACTIVE_THRESHOLD,
|
||||||
@@ -29,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
|||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { PureComponent } from "react";
|
import { PureComponent } from "react";
|
||||||
|
|
||||||
|
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ReconciledExcalidrawElement,
|
ReconciledExcalidrawElement,
|
||||||
RemoteExcalidrawElement,
|
RemoteExcalidrawElement,
|
||||||
@@ -311,6 +313,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
saveCollabRoomToFirebase = async (
|
saveCollabRoomToFirebase = async (
|
||||||
syncableElements: readonly SyncableExcalidrawElement[],
|
syncableElements: readonly SyncableExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
|
syncableElements = cloneJSON(syncableElements);
|
||||||
try {
|
try {
|
||||||
const storedElements = await saveToFirebase(
|
const storedElements = await saveToFirebase(
|
||||||
this.portal,
|
this.portal,
|
||||||
@@ -579,7 +582,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
case WS_SUBTYPES.INIT: {
|
case WS_SUBTYPES.INIT: {
|
||||||
if (!this.portal.socketInitialized) {
|
if (!this.portal.socketInitialized) {
|
||||||
this.initializeRoom({ fetchScene: false });
|
this.initializeRoom({ fetchScene: false });
|
||||||
const remoteElements = decryptedData.payload.elements;
|
const remoteElements = toBrandedType<
|
||||||
|
readonly RemoteExcalidrawElement[]
|
||||||
|
>(decryptedData.payload.elements);
|
||||||
const reconciledElements =
|
const reconciledElements =
|
||||||
this._reconcileElements(remoteElements);
|
this._reconcileElements(remoteElements);
|
||||||
this.handleRemoteSceneUpdate(reconciledElements);
|
this.handleRemoteSceneUpdate(reconciledElements);
|
||||||
@@ -593,7 +598,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
}
|
}
|
||||||
case WS_SUBTYPES.UPDATE:
|
case WS_SUBTYPES.UPDATE:
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(
|
||||||
this._reconcileElements(decryptedData.payload.elements),
|
this._reconcileElements(
|
||||||
|
toBrandedType<readonly RemoteExcalidrawElement[]>(
|
||||||
|
decryptedData.payload.elements,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||||
@@ -742,20 +751,28 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private _reconcileElements = (
|
private _reconcileElements = (
|
||||||
remoteElements: readonly ExcalidrawElement[],
|
remoteElements: readonly RemoteExcalidrawElement[],
|
||||||
): ReconciledExcalidrawElement[] => {
|
): ReconciledExcalidrawElement[] => {
|
||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
const restoredRemoteElements = restoreElements(
|
|
||||||
|
const existingElements = this.getSceneElementsIncludingDeleted();
|
||||||
|
|
||||||
|
// NOTE ideally we restore _after_ reconciliation but we can't do that
|
||||||
|
// as we'd regenerate even elements such as appState.newElement which would
|
||||||
|
// break the state
|
||||||
|
remoteElements = restoreElements(remoteElements, existingElements);
|
||||||
|
|
||||||
|
let reconciledElements = reconcileElements(
|
||||||
|
existingElements,
|
||||||
remoteElements,
|
remoteElements,
|
||||||
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
|
|
||||||
);
|
|
||||||
const reconciledElements = reconcileElements(
|
|
||||||
localElements,
|
|
||||||
restoredRemoteElements as RemoteExcalidrawElement[],
|
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
reconciledElements = bumpElementVersions(
|
||||||
|
reconciledElements,
|
||||||
|
existingElements,
|
||||||
|
);
|
||||||
|
|
||||||
// Avoid broadcasting to the rest of the collaborators the scene
|
// Avoid broadcasting to the rest of the collaborators the scene
|
||||||
// we just received!
|
// we just received!
|
||||||
// Note: this needs to be set before updating the scene as it
|
// Note: this needs to be set before updating the scene as it
|
||||||
|
|||||||
@@ -75,6 +75,39 @@ const renderCubicBezier = (
|
|||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPolygon = (data: any): data is GlobalPoint[] => {
|
||||||
|
return (
|
||||||
|
Array.isArray(data) &&
|
||||||
|
data.every((point) => Array.isArray(point) && point.length === 2)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPolygon = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
zoom: number,
|
||||||
|
points: GlobalPoint[],
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
if (points.length < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.globalAlpha = 0.3;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
|
||||||
|
}
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
context.globalAlpha = 1.0;
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||||
context.strokeStyle = "#888";
|
context.strokeStyle = "#888";
|
||||||
context.save();
|
context.save();
|
||||||
@@ -280,6 +313,14 @@ const render = (
|
|||||||
el.color,
|
el.color,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case isPolygon(el.data):
|
||||||
|
renderPolygon(
|
||||||
|
context,
|
||||||
|
appState.zoom.value,
|
||||||
|
el.data as GlobalPoint[],
|
||||||
|
el.color,
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
|
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { reconcileElements } from "@excalidraw/excalidraw";
|
import { reconcileElements } from "@excalidraw/excalidraw";
|
||||||
import { MIME_TYPES } from "@excalidraw/common";
|
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
|
||||||
import { decompressData } from "@excalidraw/excalidraw/data/encode";
|
import { decompressData } from "@excalidraw/excalidraw/data/encode";
|
||||||
import {
|
import {
|
||||||
encryptData,
|
encryptData,
|
||||||
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
|
|||||||
|
|
||||||
FirebaseSceneVersionCache.set(socket, storedElements);
|
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||||
|
|
||||||
return storedElements;
|
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromFirebase = async (
|
export const loadFromFirebase = async (
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IV_LENGTH_BYTES,
|
IV_LENGTH_BYTES,
|
||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
|
|||||||
SCENE_INIT: {
|
SCENE_INIT: {
|
||||||
type: WS_SUBTYPES.INIT;
|
type: WS_SUBTYPES.INIT;
|
||||||
payload: {
|
payload: {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly OrderedExcalidrawElement[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
SCENE_UPDATE: {
|
SCENE_UPDATE: {
|
||||||
type: WS_SUBTYPES.UPDATE;
|
type: WS_SUBTYPES.UPDATE;
|
||||||
payload: {
|
payload: {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly OrderedExcalidrawElement[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
MOUSE_LOCATION: {
|
MOUSE_LOCATION: {
|
||||||
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFromBackend = async (
|
export const importFromBackend = async (
|
||||||
id: string,
|
id: string,
|
||||||
decryptionKey: string,
|
decryptionKey: string,
|
||||||
): Promise<ImportedDataState> => {
|
): Promise<ImportedDataState> => {
|
||||||
@@ -242,45 +241,6 @@ const importFromBackend = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadScene = async (
|
|
||||||
id: string | null,
|
|
||||||
privateKey: string | null,
|
|
||||||
// Supply local state even if importing from backend to ensure we restore
|
|
||||||
// localStorage user settings which we do not persist on server.
|
|
||||||
// Non-optional so we don't forget to pass it even if `undefined`.
|
|
||||||
localDataState: ImportedDataState | undefined | null,
|
|
||||||
) => {
|
|
||||||
let data;
|
|
||||||
if (id != null && privateKey != null) {
|
|
||||||
// the private key is used to decrypt the content from the server, take
|
|
||||||
// extra care not to leak it
|
|
||||||
data = restore(
|
|
||||||
await importFromBackend(id, privateKey),
|
|
||||||
localDataState?.appState,
|
|
||||||
localDataState?.elements,
|
|
||||||
{
|
|
||||||
repairBindings: true,
|
|
||||||
refreshDimensions: false,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
data = restore(localDataState || null, null, null, {
|
|
||||||
repairBindings: true,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: data.elements,
|
|
||||||
appState: data.appState,
|
|
||||||
// note: this will always be empty because we're not storing files
|
|
||||||
// in the scene database/localStorage, and instead fetch them async
|
|
||||||
// from a different database
|
|
||||||
files: data.files,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExportToBackendResult =
|
type ExportToBackendResult =
|
||||||
| { url: null; errorMessage: string }
|
| { url: null; errorMessage: string }
|
||||||
| { url: string; errorMessage: null };
|
| { url: string; errorMessage: null };
|
||||||
|
|||||||
+47
-20
@@ -24,34 +24,35 @@ export class Debug {
|
|||||||
private static LAST_DEBUG_LOG_CALL = 0;
|
private static LAST_DEBUG_LOG_CALL = 0;
|
||||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||||
|
|
||||||
|
private static LAST_FRAME_TIMESTAMP = 0;
|
||||||
|
private static FRAME_COUNT = 0;
|
||||||
|
private static ANIMATION_FRAME_ID: null | number = null;
|
||||||
|
|
||||||
|
private static scheduleAnimationFrame = () => {
|
||||||
|
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||||
|
Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => {
|
||||||
|
if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) {
|
||||||
|
Debug.LAST_FRAME_TIMESTAMP = timestamp;
|
||||||
|
Debug.FRAME_COUNT++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||||
|
Debug.scheduleAnimationFrame();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private static setupInterval = () => {
|
private static setupInterval = () => {
|
||||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||||
console.info("%c(starting perf recording)", "color: lime");
|
console.info("%c(starting perf recording)", "color: lime");
|
||||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||||
|
Debug.scheduleAnimationFrame();
|
||||||
}
|
}
|
||||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||||
};
|
};
|
||||||
|
|
||||||
private static debugLogger = () => {
|
private static debugLogger = () => {
|
||||||
if (
|
|
||||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
|
||||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
|
||||||
) {
|
|
||||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
|
||||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
|
||||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
|
||||||
if (avg != null) {
|
|
||||||
console.info(
|
|
||||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
|
||||||
"color: blue",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.info("%c(stopping perf recording)", "color: red");
|
|
||||||
Debug.TIMES_AGGR = {};
|
|
||||||
Debug.TIMES_AVG = {};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Debug.DEBUG_LOG_TIMES) {
|
if (Debug.DEBUG_LOG_TIMES) {
|
||||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||||
if (times.length) {
|
if (times.length) {
|
||||||
@@ -66,7 +67,15 @@ export class Debug {
|
|||||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||||
if (times.length) {
|
if (times.length) {
|
||||||
const avgFrameTime = getAvgFrameTime(times);
|
const avgFrameTime = getAvgFrameTime(times);
|
||||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
console.info(
|
||||||
|
name,
|
||||||
|
`${times.length} runs: ${avgFrameTime}ms across ${
|
||||||
|
Debug.FRAME_COUNT
|
||||||
|
} frames (${getFps(avgFrameTime)} fps ~ ${lessPrecise(
|
||||||
|
(avgFrameTime / 16.67) * 100,
|
||||||
|
1,
|
||||||
|
)}% of frame budget)`,
|
||||||
|
);
|
||||||
Debug.TIMES_AVG[name] = {
|
Debug.TIMES_AVG[name] = {
|
||||||
t,
|
t,
|
||||||
times: [],
|
times: [],
|
||||||
@@ -76,6 +85,24 @@ export class Debug {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Debug.FRAME_COUNT = 0;
|
||||||
|
|
||||||
|
// Check for stop condition after logging
|
||||||
|
if (
|
||||||
|
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||||
|
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||||
|
) {
|
||||||
|
console.info("%c(stopping perf recording)", "color: red");
|
||||||
|
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||||
|
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
|
||||||
|
Debug.ANIMATION_FRAME_ID = null;
|
||||||
|
Debug.FRAME_COUNT = 0;
|
||||||
|
Debug.LAST_FRAME_TIMESTAMP = 0;
|
||||||
|
|
||||||
|
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||||
|
Debug.TIMES_AGGR = {};
|
||||||
|
Debug.TIMES_AVG = {};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public static logTime = (time?: number, name = "default") => {
|
public static logTime = (time?: number, name = "default") => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"socket.io-client": "4.7.2",
|
"socket.io-client": "4.7.2",
|
||||||
|
"uqr": "0.1.2",
|
||||||
"vite-plugin-html": "3.2.2"
|
"vite-plugin-html": "3.2.2"
|
||||||
},
|
},
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Spinner from "@excalidraw/excalidraw/components/Spinner";
|
||||||
|
|
||||||
|
interface QRCodeProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QRCode = ({ value }: QRCodeProps) => {
|
||||||
|
const [svgData, setSvgData] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
import("./qrcode.chunk")
|
||||||
|
.then(({ generateQRCodeSVG }) => {
|
||||||
|
if (mounted) {
|
||||||
|
try {
|
||||||
|
setSvgData(generateQRCodeSVG(value));
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (mounted) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!svgData) {
|
||||||
|
return (
|
||||||
|
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="ShareDialog__active__qrcode"
|
||||||
|
role="img"
|
||||||
|
aria-label="QR code for collaboration link"
|
||||||
|
dangerouslySetInnerHTML={{ __html: svgData }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -140,6 +140,31 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__qrcode {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
|
||||||
|
$size: 150px;
|
||||||
|
width: $size;
|
||||||
|
height: $size;
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
width: $size;
|
||||||
|
height: $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--loading {
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
border: 1px solid var(--dialog-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__description {
|
&__description {
|
||||||
border-top: 1px solid var(--color-gray-20);
|
border-top: 1px solid var(--color-gray-20);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
|
|||||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
|
import { QRCode } from "./QRCode";
|
||||||
|
|
||||||
import type { CollabAPI } from "../collab/Collab";
|
import type { CollabAPI } from "../collab/Collab";
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<QRCode value={activeRoomLink} />
|
||||||
<div className="ShareDialog__active__description">
|
<div className="ShareDialog__active__description">
|
||||||
<p>
|
<p>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { renderSVG } from "uqr";
|
||||||
|
|
||||||
|
export const generateQRCodeSVG = (text: string): string => {
|
||||||
|
return renderSVG(text);
|
||||||
|
};
|
||||||
@@ -55,5 +55,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rimraf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tinycolor2": "1.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/tinycolor2": "1.4.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
|
||||||
|
{
|
||||||
|
"black": "#d3d3d3",
|
||||||
|
"blue": [
|
||||||
|
"#121e26",
|
||||||
|
"#154162",
|
||||||
|
"#2273b4",
|
||||||
|
"#3791e0",
|
||||||
|
"#56a2e8",
|
||||||
|
],
|
||||||
|
"bronze": [
|
||||||
|
"#221c1a",
|
||||||
|
"#362b26",
|
||||||
|
"#5a463d",
|
||||||
|
"#917569",
|
||||||
|
"#a98d84",
|
||||||
|
],
|
||||||
|
"cyan": [
|
||||||
|
"#0a1e20",
|
||||||
|
"#004149",
|
||||||
|
"#007281",
|
||||||
|
"#0f8fa1",
|
||||||
|
"#3da5b6",
|
||||||
|
],
|
||||||
|
"grape": [
|
||||||
|
"#211a25",
|
||||||
|
"#5b3165",
|
||||||
|
"#a954be",
|
||||||
|
"#d471ed",
|
||||||
|
"#e28af8",
|
||||||
|
],
|
||||||
|
"gray": [
|
||||||
|
"#161718",
|
||||||
|
"#202325",
|
||||||
|
"#33383d",
|
||||||
|
"#6e757c",
|
||||||
|
"#b7bcc1",
|
||||||
|
],
|
||||||
|
"green": [
|
||||||
|
"#0f1d12",
|
||||||
|
"#043b0c",
|
||||||
|
"#056715",
|
||||||
|
"#16842a",
|
||||||
|
"#39994b",
|
||||||
|
],
|
||||||
|
"orange": [
|
||||||
|
"#22190d",
|
||||||
|
"#4c2b01",
|
||||||
|
"#924800",
|
||||||
|
"#cd6005",
|
||||||
|
"#f17634",
|
||||||
|
],
|
||||||
|
"pink": [
|
||||||
|
"#26191e",
|
||||||
|
"#602e40",
|
||||||
|
"#b04d70",
|
||||||
|
"#f56e9d",
|
||||||
|
"#ff8dbc",
|
||||||
|
],
|
||||||
|
"red": [
|
||||||
|
"#1f1717",
|
||||||
|
"#5a2c2c",
|
||||||
|
"#b44d4d",
|
||||||
|
"#fa6969",
|
||||||
|
"#ff8383",
|
||||||
|
],
|
||||||
|
"teal": [
|
||||||
|
"#0a1d17",
|
||||||
|
"#00422b",
|
||||||
|
"#00744b",
|
||||||
|
"#039267",
|
||||||
|
"#32a783",
|
||||||
|
],
|
||||||
|
"transparent": "#ededed00",
|
||||||
|
"violet": [
|
||||||
|
"#1f1c29",
|
||||||
|
"#4a3b72",
|
||||||
|
"#8a6cdf",
|
||||||
|
"#a885ff",
|
||||||
|
"#b595ff",
|
||||||
|
],
|
||||||
|
"white": "#121212",
|
||||||
|
"yellow": [
|
||||||
|
"#1e1900",
|
||||||
|
"#362600",
|
||||||
|
"#5f3a00",
|
||||||
|
"#905000",
|
||||||
|
"#b86200",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import {
|
||||||
|
applyDarkModeFilter,
|
||||||
|
COLOR_PALETTE,
|
||||||
|
rgbToHex,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
describe("applyDarkModeFilter", () => {
|
||||||
|
describe("basic transformations", () => {
|
||||||
|
it("transforms black to near-white", () => {
|
||||||
|
const result = applyDarkModeFilter("#000000");
|
||||||
|
// Black inverted 93% + hue rotate should be near white/light gray
|
||||||
|
expect(result).toBe("#ededed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms white to near-black", () => {
|
||||||
|
const result = applyDarkModeFilter("#ffffff");
|
||||||
|
// White inverted 93% should be near black/dark gray
|
||||||
|
expect(result).toBe("#121212");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms pure red", () => {
|
||||||
|
const result = applyDarkModeFilter("#ff0000");
|
||||||
|
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
|
||||||
|
expect(result).toBe("#ff9090");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms pure green", () => {
|
||||||
|
const result = applyDarkModeFilter("#00ff00");
|
||||||
|
// Invert 93% + hue rotate 180deg
|
||||||
|
expect(result).toBe("#008f00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms pure blue", () => {
|
||||||
|
const result = applyDarkModeFilter("#0000ff");
|
||||||
|
// Invert 93% + hue rotate 180deg produces a light purple
|
||||||
|
expect(result).toBe("#cdcdff");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("color formats", () => {
|
||||||
|
it("handles hex with hash", () => {
|
||||||
|
const result = applyDarkModeFilter("#ff0000");
|
||||||
|
// Fully opaque colors return 6-char hex
|
||||||
|
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles named colors", () => {
|
||||||
|
const result = applyDarkModeFilter("red");
|
||||||
|
// "red" = #ff0000, fully opaque
|
||||||
|
expect(result).toBe("#ff9090");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles rgb format", () => {
|
||||||
|
const result = applyDarkModeFilter("rgb(255, 0, 0)");
|
||||||
|
expect(result).toBe("#ff9090");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles rgba format and preserves alpha", () => {
|
||||||
|
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
|
||||||
|
expect(result).toMatch(/^#[0-9a-f]{8}$/);
|
||||||
|
// Alpha 0.5 = 128 in hex = 80
|
||||||
|
expect(result).toBe("#ff909080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles transparent", () => {
|
||||||
|
const result = applyDarkModeFilter("transparent");
|
||||||
|
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
|
||||||
|
expect(result).toBe("#ededed00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles shorthand hex", () => {
|
||||||
|
const result = applyDarkModeFilter("#f00");
|
||||||
|
expect(result).toBe("#ff9090");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alpha preservation", () => {
|
||||||
|
it("omits alpha for full opacity", () => {
|
||||||
|
const result = applyDarkModeFilter("#ff0000ff");
|
||||||
|
// Full opacity returns 6-char hex (no alpha suffix)
|
||||||
|
expect(result).toBe("#ff9090");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves 50% opacity", () => {
|
||||||
|
const result = applyDarkModeFilter("#ff000080");
|
||||||
|
expect(result.slice(-2)).toBe("80");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves 0% opacity", () => {
|
||||||
|
const result = applyDarkModeFilter("#ff000000");
|
||||||
|
expect(result.slice(-2)).toBe("00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("COLOR_PALETTE regression tests", () => {
|
||||||
|
it("transforms black from palette", () => {
|
||||||
|
// COLOR_PALETTE.black is #1e1e1e (not pure black)
|
||||||
|
const result = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||||
|
expect(result).toBe("#d3d3d3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms white from palette", () => {
|
||||||
|
const result = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||||
|
expect(result).toBe("#121212");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms transparent from palette", () => {
|
||||||
|
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
|
||||||
|
expect(result).toBe("#ededed00");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test each color family from the palette (all opaque, so 6-char hex)
|
||||||
|
describe("red shades", () => {
|
||||||
|
const redShades = COLOR_PALETTE.red;
|
||||||
|
it.each(redShades.map((color, i) => [color, i]))(
|
||||||
|
"transforms red shade %s (index %d)",
|
||||||
|
(color) => {
|
||||||
|
const result = applyDarkModeFilter(color as string);
|
||||||
|
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blue shades", () => {
|
||||||
|
const blueShades = COLOR_PALETTE.blue;
|
||||||
|
it.each(blueShades.map((color, i) => [color, i]))(
|
||||||
|
"transforms blue shade %s (index %d)",
|
||||||
|
(color) => {
|
||||||
|
const result = applyDarkModeFilter(color as string);
|
||||||
|
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("green shades", () => {
|
||||||
|
const greenShades = COLOR_PALETTE.green;
|
||||||
|
it.each(greenShades.map((color, i) => [color, i]))(
|
||||||
|
"transforms green shade %s (index %d)",
|
||||||
|
(color) => {
|
||||||
|
const result = applyDarkModeFilter(color as string);
|
||||||
|
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gray shades", () => {
|
||||||
|
const grayShades = COLOR_PALETTE.gray;
|
||||||
|
it.each(grayShades.map((color, i) => [color, i]))(
|
||||||
|
"transforms gray shade %s (index %d)",
|
||||||
|
(color) => {
|
||||||
|
const result = applyDarkModeFilter(color as string);
|
||||||
|
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bronze shades", () => {
|
||||||
|
const bronzeShades = COLOR_PALETTE.bronze;
|
||||||
|
it.each(bronzeShades.map((color, i) => [color, i]))(
|
||||||
|
"transforms bronze shade %s (index %d)",
|
||||||
|
(color) => {
|
||||||
|
const result = applyDarkModeFilter(color as string);
|
||||||
|
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Snapshot test for full palette to catch any regressions
|
||||||
|
it("matches snapshot for all palette colors", () => {
|
||||||
|
const transformedPalette: Record<string, string | string[]> = {};
|
||||||
|
|
||||||
|
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||||
|
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||||
|
transformedPalette.transparent = applyDarkModeFilter(
|
||||||
|
COLOR_PALETTE.transparent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transform color arrays
|
||||||
|
for (const colorName of [
|
||||||
|
"gray",
|
||||||
|
"red",
|
||||||
|
"pink",
|
||||||
|
"grape",
|
||||||
|
"violet",
|
||||||
|
"blue",
|
||||||
|
"cyan",
|
||||||
|
"teal",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"orange",
|
||||||
|
"bronze",
|
||||||
|
] as const) {
|
||||||
|
const shades = COLOR_PALETTE[colorName];
|
||||||
|
transformedPalette[colorName] = shades.map((shade) =>
|
||||||
|
applyDarkModeFilter(shade),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(transformedPalette).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("caching", () => {
|
||||||
|
it("returns same result for same input (cached)", () => {
|
||||||
|
const result1 = applyDarkModeFilter("#ff0000");
|
||||||
|
const result2 = applyDarkModeFilter("#ff0000");
|
||||||
|
expect(result1).toBe(result2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rgbToHex", () => {
|
||||||
|
describe("basic RGB conversion", () => {
|
||||||
|
it("converts black (0,0,0)", () => {
|
||||||
|
expect(rgbToHex(0, 0, 0)).toBe("#000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts white (255,255,255)", () => {
|
||||||
|
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts red (255,0,0)", () => {
|
||||||
|
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts green (0,255,0)", () => {
|
||||||
|
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts blue (0,0,255)", () => {
|
||||||
|
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts arbitrary color", () => {
|
||||||
|
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("leading zeros preservation", () => {
|
||||||
|
it("preserves leading zeros for low values", () => {
|
||||||
|
expect(rgbToHex(0, 0, 1)).toBe("#000001");
|
||||||
|
expect(rgbToHex(0, 1, 0)).toBe("#000100");
|
||||||
|
expect(rgbToHex(1, 0, 0)).toBe("#010000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves zeros for single-digit hex values", () => {
|
||||||
|
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alpha handling", () => {
|
||||||
|
it("omits alpha when undefined", () => {
|
||||||
|
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||||
|
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits alpha when fully opaque (1)", () => {
|
||||||
|
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes alpha for semi-transparent (0.5)", () => {
|
||||||
|
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
|
||||||
|
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes alpha for fully transparent (0)", () => {
|
||||||
|
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes alpha for near-opaque (0.99)", () => {
|
||||||
|
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
|
||||||
|
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pads alpha with leading zero when needed", () => {
|
||||||
|
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
|
||||||
|
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,121 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
import tinycolor from "tinycolor2";
|
||||||
|
|
||||||
|
import { clamp } from "@excalidraw/math";
|
||||||
|
import { degreesToRadians } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import type { Degrees } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Merge } from "./utility-types";
|
import type { Merge } from "./utility-types";
|
||||||
|
|
||||||
|
export { tinycolor };
|
||||||
|
|
||||||
|
// Browser-only cache to avoid memory leaks on server
|
||||||
|
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||||
|
typeof window !== "undefined" ? new Map() : null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dark mode color transformation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function cssHueRotate(
|
||||||
|
red: number,
|
||||||
|
green: number,
|
||||||
|
blue: number,
|
||||||
|
degrees: Degrees,
|
||||||
|
): { r: number; g: number; b: number } {
|
||||||
|
// normalize
|
||||||
|
const r = red / 255;
|
||||||
|
const g = green / 255;
|
||||||
|
const b = blue / 255;
|
||||||
|
|
||||||
|
// Convert degrees to radians
|
||||||
|
const a = degreesToRadians(degrees);
|
||||||
|
|
||||||
|
const c = Math.cos(a);
|
||||||
|
const s = Math.sin(a);
|
||||||
|
|
||||||
|
// rotation matrix
|
||||||
|
const matrix = [
|
||||||
|
0.213 + c * 0.787 - s * 0.213,
|
||||||
|
0.715 - c * 0.715 - s * 0.715,
|
||||||
|
0.072 - c * 0.072 + s * 0.928,
|
||||||
|
0.213 - c * 0.213 + s * 0.143,
|
||||||
|
0.715 + c * 0.285 + s * 0.14,
|
||||||
|
0.072 - c * 0.072 - s * 0.283,
|
||||||
|
0.213 - c * 0.213 - s * 0.787,
|
||||||
|
0.715 - c * 0.715 + s * 0.715,
|
||||||
|
0.072 + c * 0.928 + s * 0.072,
|
||||||
|
];
|
||||||
|
|
||||||
|
// transform
|
||||||
|
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
|
||||||
|
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
|
||||||
|
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
|
||||||
|
|
||||||
|
// clamp the values to [0, 1] range and convert back to [0, 255]
|
||||||
|
return {
|
||||||
|
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
|
||||||
|
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
|
||||||
|
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssInvert = (
|
||||||
|
r: number,
|
||||||
|
g: number,
|
||||||
|
b: number,
|
||||||
|
percent: number,
|
||||||
|
): { r: number; g: number; b: number } => {
|
||||||
|
const p = clamp(percent, 0, 100) / 100;
|
||||||
|
|
||||||
|
// Function to invert a single color component
|
||||||
|
const invertComponent = (color: number): number => {
|
||||||
|
// Apply the invert formula
|
||||||
|
const inverted = color * (1 - p) + (255 - color) * p;
|
||||||
|
// Round to the nearest integer and clamp to [0, 255]
|
||||||
|
return Math.round(clamp(inverted, 0, 255));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate the inverted RGB components
|
||||||
|
const invertedR = invertComponent(r);
|
||||||
|
const invertedG = invertComponent(g);
|
||||||
|
const invertedB = invertComponent(b);
|
||||||
|
|
||||||
|
return { r: invertedR, g: invertedG, b: invertedB };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyDarkModeFilter = (color: string): string => {
|
||||||
|
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tc = tinycolor(color);
|
||||||
|
const alpha = tc.getAlpha();
|
||||||
|
|
||||||
|
// order of operations matters
|
||||||
|
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||||
|
const rgb = tc.toRgb();
|
||||||
|
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||||
|
const rotated = cssHueRotate(
|
||||||
|
inverted.r,
|
||||||
|
inverted.g,
|
||||||
|
inverted.b,
|
||||||
|
180 as Degrees,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||||
|
|
||||||
|
if (DARK_MODE_COLORS_CACHE) {
|
||||||
|
DARK_MODE_COLORS_CACHE.set(color, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||||
|
|
||||||
// FIXME can't put to utils.ts rn because of circular dependency
|
// FIXME can't put to utils.ts rn because of circular dependency
|
||||||
@@ -167,7 +281,22 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
|
|||||||
COLOR_PALETTE.red[index],
|
COLOR_PALETTE.red[index],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const rgbToHex = (r: number, g: number, b: number) =>
|
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||||
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
|
||||||
|
// then slice(1) removes the leading "1" to get exactly 6 hex digits
|
||||||
|
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
|
||||||
|
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
|
||||||
|
.toString(16)
|
||||||
|
.slice(1)}`;
|
||||||
|
if (a !== undefined && a < 1) {
|
||||||
|
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
|
||||||
|
// e.g. 0.5 -> 128 -> "80"
|
||||||
|
const alphaHex = Math.round(a * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `${hex6}${alphaHex}`;
|
||||||
|
}
|
||||||
|
return hex6;
|
||||||
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -305,9 +305,6 @@ export const IDLE_THRESHOLD = 60_000;
|
|||||||
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
||||||
export const ACTIVE_THRESHOLD = 3_000;
|
export const ACTIVE_THRESHOLD = 3_000;
|
||||||
|
|
||||||
// duplicates --theme-filter, should be removed soon
|
|
||||||
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
|
|
||||||
|
|
||||||
export const URL_QUERY_KEYS = {
|
export const URL_QUERY_KEYS = {
|
||||||
addLibrary: "addLibrary",
|
addLibrary: "addLibrary",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
Zoom,
|
Zoom,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { COLOR_PALETTE } from "./colors";
|
import { tinycolor } from "./colors";
|
||||||
import {
|
import {
|
||||||
DEFAULT_VERSION,
|
DEFAULT_VERSION,
|
||||||
ENV,
|
ENV,
|
||||||
@@ -549,13 +549,7 @@ export const mapFind = <T, K>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isTransparent = (color: string) => {
|
export const isTransparent = (color: string) => {
|
||||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
return tinycolor(color).getAlpha() === 0;
|
||||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
|
||||||
return (
|
|
||||||
isRGBTransparent ||
|
|
||||||
isRRGGBBTransparent ||
|
|
||||||
color === COLOR_PALETTE.transparent
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvablePromise<T> = Promise<T> & {
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
@@ -1157,39 +1151,69 @@ export const normalizeEOL = (str: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
type HasBrand<T> = {
|
export type HasBrand<T> = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
|
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
|
||||||
}[keyof T];
|
}[keyof T];
|
||||||
|
|
||||||
type RemoveAllBrands<T> = HasBrand<T> extends true
|
type RemoveAllBrands<T> = HasBrand<T> extends true
|
||||||
? {
|
? {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
|
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
|
||||||
|
? never
|
||||||
|
: K]: T[K];
|
||||||
}
|
}
|
||||||
: never;
|
: T;
|
||||||
|
|
||||||
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
|
// For accepting values - uses loose matching for branded types
|
||||||
// currently does not cover all types (e.g. tuples, promises...)
|
// Preserves readonly modifier: mutable array requires mutable input
|
||||||
type Unbrand<T> = T extends Map<infer E, infer F>
|
type UnbrandForValue<T> = T extends Map<infer E, infer F>
|
||||||
? Map<E, F>
|
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
|
||||||
: T extends Set<infer E>
|
: T extends Set<infer E>
|
||||||
? Set<E>
|
? Set<UnbrandForValue<E>>
|
||||||
: T extends Array<infer E>
|
: T extends readonly any[]
|
||||||
? Array<E>
|
? T extends any[]
|
||||||
|
? unknown[] // mutable array - require mutable input
|
||||||
|
: readonly unknown[] // readonly array - accept readonly input
|
||||||
: RemoveAllBrands<T>;
|
: RemoveAllBrands<T>;
|
||||||
|
|
||||||
|
// For return types - preserves array element unbranding
|
||||||
|
export type Unbrand<T> = T extends Map<infer E, infer F>
|
||||||
|
? Map<Unbrand<E>, Unbrand<F>>
|
||||||
|
: T extends Set<infer E>
|
||||||
|
? Set<Unbrand<E>>
|
||||||
|
: T extends readonly (infer E)[]
|
||||||
|
? Array<Unbrand<E>>
|
||||||
|
: RemoveAllBrands<T>;
|
||||||
|
|
||||||
|
export type CombineBrands<BrandedType, CurrentType> =
|
||||||
|
BrandedType extends readonly (infer BE)[]
|
||||||
|
? CurrentType extends readonly (infer CE)[]
|
||||||
|
? Array<CE & BE>
|
||||||
|
: CurrentType & BrandedType
|
||||||
|
: CurrentType & BrandedType;
|
||||||
|
|
||||||
|
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
|
||||||
|
? T[]
|
||||||
|
: HasBrand<T> extends true
|
||||||
|
? CombineBrands<T, Required>[]
|
||||||
|
: Required[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes type into a branded type, ensuring that value is assignable to
|
* Makes type into a branded type, ensuring that value is assignable to
|
||||||
* the base ubranded type. Optionally you can explicitly supply current value
|
* the base unbranded type. Optionally you can explicitly supply current value
|
||||||
* type to combine both (useful for composite branded types. Make sure you
|
* type to combine both (useful for composite branded types. Make sure you
|
||||||
* compose branded types which are not composite themselves.)
|
* compose branded types which are not composite themselves.)
|
||||||
*/
|
*/
|
||||||
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
|
export function toBrandedType<BrandedType>(
|
||||||
value: Unbrand<BrandedType>,
|
value: UnbrandForValue<BrandedType>,
|
||||||
) => {
|
): BrandedType;
|
||||||
return value as CurrentType & BrandedType;
|
export function toBrandedType<BrandedType, CurrentType>(
|
||||||
};
|
value: CurrentType,
|
||||||
|
): CombineBrands<BrandedType, CurrentType>;
|
||||||
|
export function toBrandedType(value: unknown) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Polygon = GlobalPoint[];
|
||||||
|
|
||||||
export type DebugElement = {
|
export type DebugElement = {
|
||||||
color: string;
|
color: string;
|
||||||
data: LineSegment<GlobalPoint> | Curve<GlobalPoint>;
|
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | Polygon;
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +131,20 @@ export const debugDrawBounds = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const debugDrawPolygon = (
|
||||||
|
points: GlobalPoint[],
|
||||||
|
opts?: {
|
||||||
|
color?: string;
|
||||||
|
permanent?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
addToCurrentFrame({
|
||||||
|
color: opts?.color ?? "blue",
|
||||||
|
data: points,
|
||||||
|
permanent: !!opts?.permanent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const debugDrawPoints = (
|
export const debugDrawPoints = (
|
||||||
{
|
{
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -897,6 +897,7 @@ export const getArrowheadPoints = (
|
|||||||
return [x2, y2, x3, y3, x4, y4];
|
return [x2, y2, x3, y3, x4, y4];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO reuse shape.ts
|
||||||
const generateLinearElementShape = (
|
const generateLinearElementShape = (
|
||||||
element: ExcalidrawLinearElement,
|
element: ExcalidrawLinearElement,
|
||||||
): Drawable => {
|
): Drawable => {
|
||||||
@@ -954,7 +955,7 @@ const getLinearElementRotatedBounds = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// first element is always the curve
|
// first element is always the curve
|
||||||
const cachedShape = ShapeCache.get(element)?.[0];
|
const cachedShape = ShapeCache.get(element, null)?.[0];
|
||||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||||
const ops = getCurvePathOps(shape);
|
const ops = getCurvePathOps(shape);
|
||||||
const transformXY = ([x, y]: GlobalPoint) =>
|
const transformXY = ([x, y]: GlobalPoint) =>
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import {
|
||||||
|
pointFrom,
|
||||||
|
pointDistance,
|
||||||
|
type LocalPoint,
|
||||||
|
vectorFromPoint,
|
||||||
|
vectorNormalize,
|
||||||
|
lineSegment,
|
||||||
|
type GlobalPoint,
|
||||||
|
type Polygon,
|
||||||
|
polygonFromPoints,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
import { debugDrawLine, debugDrawPolygon } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import type { ExcalidrawFreeDrawElement } from "./types";
|
||||||
|
|
||||||
|
// Number of segments to approximate each semicircular cap
|
||||||
|
const CAP_SEGMENTS = 20;
|
||||||
|
|
||||||
|
// Minimum radius to avoid degenerate shapes
|
||||||
|
const MIN_RADIUS = 0.05;
|
||||||
|
|
||||||
|
// Pressure to radius multiplier (scaled by strokeWidth)
|
||||||
|
const PRESSURE_RADIUS_MULTIPLIER = 2.0;
|
||||||
|
|
||||||
|
// Minimum distance between points to avoid numerical instability
|
||||||
|
const MIN_POINT_DISTANCE = 0.001;
|
||||||
|
|
||||||
|
// Epsilon for filtering near-duplicate points in polygons
|
||||||
|
const EPSILON = 0.01;
|
||||||
|
|
||||||
|
// Simple union implementation taking advantage of the following facts:
|
||||||
|
// - The ovoids are generated in sequence along the stroke path
|
||||||
|
// - Each ovoid overlaps only with its immediate neighbors
|
||||||
|
// - The ovoids are convex shapes
|
||||||
|
|
||||||
|
// Therefore, we can simply stitch together the outer edges of the ovoids
|
||||||
|
// by taking the first half of the first ovoid and the second half of the last ovoid,
|
||||||
|
// and connecting them with the outer edges of the intermediate ovoids. The overlapping
|
||||||
|
// ovoid caps are always the same radius at the shared points, so they align perfectly.
|
||||||
|
// It should be easy to find the closest point to the side of the previous side segment and
|
||||||
|
// one of the closest points on the next ovoid's start cap.
|
||||||
|
function chainOvoidsIntoSinglePolygon<P extends LocalPoint | GlobalPoint>(
|
||||||
|
records: {
|
||||||
|
polygon: Polygon<P>;
|
||||||
|
firstPoint: P;
|
||||||
|
secondPoint: P;
|
||||||
|
}[],
|
||||||
|
): Polygon<P> | null {
|
||||||
|
if (records.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.length === 1) {
|
||||||
|
return records[0].polygon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capPointCount = CAP_SEGMENTS + 1;
|
||||||
|
|
||||||
|
const isClosedPolygon = (points: P[]) => {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const first = points[0];
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
return first[0] === last[0] && first[1] === last[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPolygon = (points: P[]) =>
|
||||||
|
isClosedPolygon(points) ? points.slice(0, -1) : points.slice();
|
||||||
|
|
||||||
|
const distanceSq = (a: P, b: P) => {
|
||||||
|
const dx = a[0] - b[0];
|
||||||
|
const dy = a[1] - b[1];
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceToSegmentSq = (p: P, a: P, b: P) => {
|
||||||
|
const abx = b[0] - a[0];
|
||||||
|
const aby = b[1] - a[1];
|
||||||
|
const apx = p[0] - a[0];
|
||||||
|
const apy = p[1] - a[1];
|
||||||
|
const abLenSq = abx * abx + aby * aby;
|
||||||
|
|
||||||
|
if (abLenSq === 0) {
|
||||||
|
return distanceSq(p, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq));
|
||||||
|
const closest = pointFrom<P>(a[0] + abx * t, a[1] + aby * t);
|
||||||
|
return distanceSq(p, closest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closestIndexToSegment = (points: P[], a: P, b: P) => {
|
||||||
|
let bestIndex = 0;
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const dist = distanceToSegmentSq(points[i], a, b);
|
||||||
|
if (dist < bestDistance) {
|
||||||
|
bestDistance = dist;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushIfDistinct = (points: P[], point: P) => {
|
||||||
|
if (points.length === 0) {
|
||||||
|
points.push(point);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (distanceSq(points[points.length - 1], point) > EPSILON * EPSILON) {
|
||||||
|
points.push(point);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ovoids = records.map((record) => {
|
||||||
|
const open = openPolygon(record.polygon);
|
||||||
|
|
||||||
|
if (open.length < capPointCount * 2) {
|
||||||
|
return {
|
||||||
|
cap1: open,
|
||||||
|
cap2: [] as P[],
|
||||||
|
p1Right: open[0],
|
||||||
|
p1Left: open[open.length - 1],
|
||||||
|
p2Left: open[0],
|
||||||
|
p2Right: open[open.length - 1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap1 = open.slice(0, capPointCount);
|
||||||
|
const cap2 = open.slice(capPointCount, capPointCount * 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cap1,
|
||||||
|
cap2,
|
||||||
|
p1Right: cap1[0],
|
||||||
|
p1Left: cap1[cap1.length - 1],
|
||||||
|
p2Left: cap2[0],
|
||||||
|
p2Right: cap2[cap2.length - 1],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftChain: P[] = [];
|
||||||
|
const rightChain: P[] = [];
|
||||||
|
|
||||||
|
ovoids[0].cap1.forEach((point) => pushIfDistinct(leftChain, point));
|
||||||
|
pushIfDistinct(rightChain, ovoids[0].p1Right);
|
||||||
|
|
||||||
|
for (let i = 0; i < ovoids.length; i++) {
|
||||||
|
const current = ovoids[i];
|
||||||
|
|
||||||
|
pushIfDistinct(leftChain, current.p2Left);
|
||||||
|
pushIfDistinct(rightChain, current.p2Right);
|
||||||
|
|
||||||
|
if (i + 1 >= ovoids.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = ovoids[i + 1];
|
||||||
|
|
||||||
|
const leftIndex = closestIndexToSegment(
|
||||||
|
next.cap1,
|
||||||
|
current.p1Left,
|
||||||
|
current.p2Left,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = leftIndex; j < next.cap1.length; j++) {
|
||||||
|
pushIfDistinct(leftChain, next.cap1[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightIndex = closestIndexToSegment(
|
||||||
|
next.cap1,
|
||||||
|
current.p1Right,
|
||||||
|
current.p2Right,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = rightIndex; j >= 0; j--) {
|
||||||
|
pushIfDistinct(rightChain, next.cap1[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastOvoid = ovoids[ovoids.length - 1];
|
||||||
|
lastOvoid.cap2.forEach((point) => pushIfDistinct(leftChain, point));
|
||||||
|
|
||||||
|
const rightChainReversed = rightChain.slice(0, -1).reverse();
|
||||||
|
const outline = filterNearDuplicates<P>([
|
||||||
|
...leftChain,
|
||||||
|
...rightChainReversed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return polygonFromPoints<P>(outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the radius for a point based on pressure and strokeWidth.
|
||||||
|
* Pressure is typically in [0, 1] range, default to 0.5 if simulating.
|
||||||
|
*/
|
||||||
|
function getRadiusForPressure(pressure: number, strokeWidth: number): number {
|
||||||
|
return Math.max(
|
||||||
|
MIN_RADIUS,
|
||||||
|
pressure * strokeWidth * PRESSURE_RADIUS_MULTIPLIER,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate points along a semicircular arc (dome/cap).
|
||||||
|
* The arc goes from startAngle to endAngle (counterclockwise).
|
||||||
|
*
|
||||||
|
* @param center - Center point of the arc
|
||||||
|
* @param radius - Radius of the arc
|
||||||
|
* @param startAngle - Start angle in radians
|
||||||
|
* @param endAngle - End angle in radians (counterclockwise from start)
|
||||||
|
* @param segments - Number of segments to divide the arc into
|
||||||
|
* @returns Array of points along the arc
|
||||||
|
*/
|
||||||
|
function generateArcPoints<P extends LocalPoint | GlobalPoint>(
|
||||||
|
center: LocalPoint,
|
||||||
|
radius: number,
|
||||||
|
startAngle: number,
|
||||||
|
endAngle: number,
|
||||||
|
segments: number,
|
||||||
|
): P[] {
|
||||||
|
const points: P[] = [];
|
||||||
|
const angleStep = (endAngle - startAngle) / segments;
|
||||||
|
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const angle = startAngle + i * angleStep;
|
||||||
|
points.push(
|
||||||
|
pointFrom<P>(
|
||||||
|
center[0] + radius * Math.cos(angle),
|
||||||
|
center[1] + radius * Math.sin(angle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an ovoid shape between two consecutive points.
|
||||||
|
* The ovoid consists of:
|
||||||
|
* - A semicircular cap at the first point (facing away from point 2)
|
||||||
|
* - A semicircular cap at the second point (facing away from point 1)
|
||||||
|
* - Connecting lines (tangent lines between the two circles)
|
||||||
|
*
|
||||||
|
* @param p1 - First point
|
||||||
|
* @param r1 - Radius at first point (from pressure)
|
||||||
|
* @param p2 - Second point
|
||||||
|
* @param r2 - Radius at second point (from pressure)
|
||||||
|
* @param forcePerpendicularCap1 - Force cap1 to be perpendicular (for stroke start)
|
||||||
|
* @param forcePerpendicularCap2 - Force cap2 to be perpendicular (for stroke end)
|
||||||
|
* @returns Array of points forming the ovoid polygon
|
||||||
|
*/
|
||||||
|
function createOvoid<P extends LocalPoint | GlobalPoint>(
|
||||||
|
p1: LocalPoint,
|
||||||
|
r1: number,
|
||||||
|
p2: LocalPoint,
|
||||||
|
r2: number,
|
||||||
|
forcePerpendicularCap1: boolean = false,
|
||||||
|
forcePerpendicularCap2: boolean = false,
|
||||||
|
): Polygon<P> {
|
||||||
|
const dist = pointDistance(p1, p2);
|
||||||
|
|
||||||
|
// If points are too close, create a circle at the midpoint
|
||||||
|
if (dist < MIN_POINT_DISTANCE) {
|
||||||
|
const avgRadius = (r1 + r2) / 2;
|
||||||
|
return polygonFromPoints<P>(
|
||||||
|
generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction vector from p1 to p2
|
||||||
|
const dirVec = vectorFromPoint(p2, p1);
|
||||||
|
const normalizedDir = vectorNormalize(dirVec);
|
||||||
|
|
||||||
|
// Calculate the angle of the direction vector
|
||||||
|
const baseAngle = Math.atan2(normalizedDir[1], normalizedDir[0]);
|
||||||
|
|
||||||
|
// For connecting the circles with tangent lines when radii differ,
|
||||||
|
// we need to compute the tangent points
|
||||||
|
// When r1 != r2, the tangent lines are not perpendicular to the center line
|
||||||
|
|
||||||
|
// Tangent angle offset (when radii differ)
|
||||||
|
const radiusDiff = r1 - r2;
|
||||||
|
const tangentAngleOffset =
|
||||||
|
dist > Math.abs(radiusDiff) ? Math.asin(radiusDiff / dist) : 0;
|
||||||
|
|
||||||
|
// Compute tangent points on each circle
|
||||||
|
// The perpendicular offset needs to be adjusted for the tangent angle
|
||||||
|
const tangentPerpAngle = baseAngle + Math.PI / 2 + tangentAngleOffset;
|
||||||
|
const purePerpAngle = baseAngle + Math.PI / 2;
|
||||||
|
|
||||||
|
// Cap at p1 (semicircle facing AWAY from p2, i.e., toward baseAngle + PI)
|
||||||
|
// Use perpendicular cap for stroke start, otherwise use tangent-adjusted
|
||||||
|
const p1PerpAngle = forcePerpendicularCap1 ? purePerpAngle : tangentPerpAngle;
|
||||||
|
const p1RightAngle = p1PerpAngle;
|
||||||
|
const p1LeftAngle = p1PerpAngle + Math.PI;
|
||||||
|
const cap1Points = generateArcPoints<P>(
|
||||||
|
p1,
|
||||||
|
r1,
|
||||||
|
p1RightAngle,
|
||||||
|
p1LeftAngle,
|
||||||
|
CAP_SEGMENTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cap at p2 (semicircle facing AWAY from p1, i.e., toward baseAngle)
|
||||||
|
// Use perpendicular cap for stroke end, otherwise use tangent-adjusted
|
||||||
|
const p2PerpAngle = forcePerpendicularCap2 ? purePerpAngle : tangentPerpAngle;
|
||||||
|
const p2LeftAngle = p2PerpAngle + Math.PI;
|
||||||
|
const p2RightAngle = p2LeftAngle + Math.PI;
|
||||||
|
const cap2Points = generateArcPoints<P>(
|
||||||
|
p2,
|
||||||
|
r2,
|
||||||
|
p2LeftAngle,
|
||||||
|
p2RightAngle,
|
||||||
|
CAP_SEGMENTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assemble the ovoid polygon:
|
||||||
|
// cap1 goes around the back of p1, cap2 goes around the front of p2
|
||||||
|
// The arc endpoints naturally connect with the tangent lines
|
||||||
|
const ovoidPoints: P[] = [
|
||||||
|
...cap1Points, // p1's back cap
|
||||||
|
...cap2Points, // p2's front cap
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter out near-duplicate consecutive points to avoid numerical issues
|
||||||
|
return polygonFromPoints<P>(filterNearDuplicates<P>(ovoidPoints));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out consecutive points that are too close together.
|
||||||
|
* This prevents numerical instability in polygon boolean operations.
|
||||||
|
*/
|
||||||
|
function filterNearDuplicates<P extends LocalPoint | GlobalPoint>(
|
||||||
|
points: P[],
|
||||||
|
): P[] {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered: P[] = [points[0]];
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const prev = filtered[filtered.length - 1];
|
||||||
|
const curr = points[i];
|
||||||
|
const dx = curr[0] - prev[0];
|
||||||
|
const dy = curr[1] - prev[1];
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
// Only add if far enough from previous point
|
||||||
|
if (distSq > EPSILON * EPSILON) {
|
||||||
|
filtered.push(curr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if last point is too close to first point
|
||||||
|
if (filtered.length > 2) {
|
||||||
|
const first = filtered[0];
|
||||||
|
const last = filtered[filtered.length - 1];
|
||||||
|
const dx = last[0] - first[0];
|
||||||
|
const dy = last[1] - first[1];
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (distSq < EPSILON * EPSILON) {
|
||||||
|
filtered.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.length >= 3 ? filtered : points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the outline of a freedraw element using the ovoid-union approach.
|
||||||
|
*
|
||||||
|
* This creates ovoid shapes between each consecutive pair of points,
|
||||||
|
* where each ovoid is defined by:
|
||||||
|
* - Semicircular caps at each point (radius based on pressure)
|
||||||
|
* - Connecting tangent lines between the caps
|
||||||
|
*
|
||||||
|
* All ovoids are then unioned together to form the final outline.
|
||||||
|
*
|
||||||
|
* @param element - The freedraw element to generate outline for
|
||||||
|
* @returns Array of [x, y] points representing the outline polygon
|
||||||
|
*/
|
||||||
|
export function generateFreeDrawOvoidOutline(
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
): [number, number][] {
|
||||||
|
const { x, y, points, pressures, simulatePressure, strokeWidth } = element;
|
||||||
|
|
||||||
|
// Debug draw the raw segments from the freedraw element points
|
||||||
|
const colors = ["red", "green", "blue", "orange", "purple"];
|
||||||
|
|
||||||
|
points.forEach((pt, i) => {
|
||||||
|
if (i === points.length - 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugDrawLine(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom<GlobalPoint>(x + pt[0], y + pt[1]),
|
||||||
|
pointFrom<GlobalPoint>(x + points[i + 1][0], y + points[i + 1][1]),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single point: just return a circle
|
||||||
|
if (points.length === 1) {
|
||||||
|
const pressure = simulatePressure ? 0.5 : pressures[0] ?? 0.5;
|
||||||
|
const radius = getRadiusForPressure(pressure, strokeWidth);
|
||||||
|
const circlePoints = generateArcPoints(
|
||||||
|
points[0] as LocalPoint,
|
||||||
|
radius,
|
||||||
|
0,
|
||||||
|
Math.PI * 2,
|
||||||
|
CAP_SEGMENTS * 2,
|
||||||
|
);
|
||||||
|
return circlePoints.map((p) => [p[0], p[1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ovoids for each consecutive pair of points
|
||||||
|
const ovoids: Polygon<LocalPoint>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const p1 = points[i] as LocalPoint;
|
||||||
|
const p2 = points[i + 1] as LocalPoint;
|
||||||
|
|
||||||
|
// Get pressures (use 0.5 as default when simulating)
|
||||||
|
const pressure1 = simulatePressure ? 0.5 : pressures[i] ?? 0.5;
|
||||||
|
const pressure2 = simulatePressure ? 0.5 : pressures[i + 1] ?? 0.5;
|
||||||
|
|
||||||
|
const r1 = getRadiusForPressure(pressure1, strokeWidth);
|
||||||
|
const r2 = getRadiusForPressure(pressure2, strokeWidth);
|
||||||
|
|
||||||
|
// Force perpendicular caps at stroke endpoints
|
||||||
|
const isFirstSegment = i === 0;
|
||||||
|
const isLastSegment = i === points.length - 2;
|
||||||
|
|
||||||
|
const ovoidPoints = createOvoid<LocalPoint>(
|
||||||
|
p1,
|
||||||
|
r1,
|
||||||
|
p2,
|
||||||
|
r2,
|
||||||
|
isFirstSegment, // Force perpendicular cap at stroke start
|
||||||
|
isLastSegment, // Force perpendicular cap at stroke end
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw the ovoid with different colors for debugging
|
||||||
|
debugDrawPolygon(
|
||||||
|
ovoidPoints.map((p) => pointFrom<GlobalPoint>(x + p[0], y + p[1])),
|
||||||
|
{
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ovoidPoints.length >= 3) {
|
||||||
|
ovoids.push(ovoidPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ovoids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union all ovoids together
|
||||||
|
const result = chainOvoidsIntoSinglePolygon<LocalPoint>(
|
||||||
|
ovoids.map((poly, i) => ({
|
||||||
|
polygon: poly,
|
||||||
|
firstPoint: points[i],
|
||||||
|
secondPoint: points[i + 1],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first (outer) polygon
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the ovoid outline to an SVG path string. Uses quadratic curves
|
||||||
|
* for smoothing.
|
||||||
|
*/
|
||||||
|
export function generateFreeDrawOvoidSvgPath(
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
): string {
|
||||||
|
const points = generateFreeDrawOvoidOutline(element);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
console.warn("No outline points generated for freedraw element");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points.length < 3) {
|
||||||
|
console.warn("Not enough outline points to form a closed path");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a similar approach to the original getSvgPathFromStroke
|
||||||
|
// but with the ovoid-generated points
|
||||||
|
const med = (a: number[], b: number[]) => [
|
||||||
|
(a[0] + b[0]) / 2,
|
||||||
|
(a[1] + b[1]) / 2,
|
||||||
|
];
|
||||||
|
|
||||||
|
const pathData = points
|
||||||
|
// Build a closed, smoothed SVG path from the outline polygon
|
||||||
|
.reduce(
|
||||||
|
(acc: (string | number[])[], point, i, arr) => {
|
||||||
|
if (i === points.length - 1) {
|
||||||
|
// For the last point, add a line-to ("L") back to the first point and close
|
||||||
|
// ("Z").
|
||||||
|
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||||
|
} else {
|
||||||
|
// Use a single quadratic command ("Q") and then emit point + midpoint pairs
|
||||||
|
// so each segment curves through the current point toward the midpoint of
|
||||||
|
// the next segment.
|
||||||
|
acc.push(point, med(point, arr[i + 1]));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
// Start with a move-to ("M") to the first point.
|
||||||
|
["M", points[0], "Q"],
|
||||||
|
)
|
||||||
|
.join(" ")
|
||||||
|
// Trim excessive float precision to keep the path string compact/stable.
|
||||||
|
.replace(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, "$1");
|
||||||
|
|
||||||
|
return pathData;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
isRTL,
|
isRTL,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
invariant,
|
invariant,
|
||||||
|
applyDarkModeFilter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -78,16 +78,8 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { StrokeOptions } from "perfect-freehand";
|
|
||||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
|
|
||||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
|
||||||
// as a temp hack to make images in dark theme look closer to original
|
|
||||||
// color scheme (it's still not quite there and the colors look slightly
|
|
||||||
// desatured, alas...)
|
|
||||||
export const IMAGE_INVERT_FILTER =
|
|
||||||
"invert(100%) hue-rotate(180deg) saturate(1.25)";
|
|
||||||
|
|
||||||
const isPendingImageElement = (
|
const isPendingImageElement = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
@@ -95,19 +87,6 @@ const isPendingImageElement = (
|
|||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
!renderConfig.imageCache.has(element.fileId);
|
!renderConfig.imageCache.has(element.fileId);
|
||||||
|
|
||||||
const shouldResetImageFilter = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
|
||||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
appState.theme === THEME.DARK &&
|
|
||||||
isInitializedImageElement(element) &&
|
|
||||||
!isPendingImageElement(element, renderConfig) &&
|
|
||||||
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCanvasPadding = (element: ExcalidrawElement) => {
|
const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "freedraw":
|
case "freedraw":
|
||||||
@@ -272,11 +251,6 @@ const generateElementCanvas = (
|
|||||||
|
|
||||||
const rc = rough.canvas(canvas);
|
const rc = rough.canvas(canvas);
|
||||||
|
|
||||||
// in dark theme, revert the image color filter
|
|
||||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
|
||||||
context.filter = IMAGE_INVERT_FILTER;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
@@ -421,7 +395,8 @@ const drawElementOnCanvas = (
|
|||||||
case "ellipse": {
|
case "ellipse": {
|
||||||
context.lineJoin = "round";
|
context.lineJoin = "round";
|
||||||
context.lineCap = "round";
|
context.lineCap = "round";
|
||||||
rc.draw(ShapeCache.get(element)!);
|
|
||||||
|
rc.draw(ShapeCache.generateElementShape(element, renderConfig));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "arrow":
|
case "arrow":
|
||||||
@@ -429,26 +404,31 @@ const drawElementOnCanvas = (
|
|||||||
context.lineJoin = "round";
|
context.lineJoin = "round";
|
||||||
context.lineCap = "round";
|
context.lineCap = "round";
|
||||||
|
|
||||||
ShapeCache.get(element)!.forEach((shape) => {
|
ShapeCache.generateElementShape(element, renderConfig).forEach(
|
||||||
rc.draw(shape);
|
(shape) => {
|
||||||
});
|
rc.draw(shape);
|
||||||
|
},
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
// Draw directly to canvas
|
// Draw directly to canvas
|
||||||
context.save();
|
context.save();
|
||||||
context.fillStyle = element.strokeColor;
|
|
||||||
|
|
||||||
const path = getFreeDrawPath2D(element) as Path2D;
|
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
||||||
const fillShape = ShapeCache.get(element);
|
|
||||||
|
|
||||||
if (fillShape) {
|
for (const shape of shapes) {
|
||||||
rc.draw(fillShape);
|
if (typeof shape === "string") {
|
||||||
|
context.fillStyle =
|
||||||
|
renderConfig.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor;
|
||||||
|
context.fill(new Path2D(shape));
|
||||||
|
} else {
|
||||||
|
rc.draw(shape);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.fillStyle = element.strokeColor;
|
|
||||||
context.fill(path);
|
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -506,7 +486,10 @@ const drawElementOnCanvas = (
|
|||||||
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
||||||
context.save();
|
context.save();
|
||||||
context.font = getFontString(element);
|
context.font = getFontString(element);
|
||||||
context.fillStyle = element.strokeColor;
|
context.fillStyle =
|
||||||
|
renderConfig.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor;
|
||||||
context.textAlign = element.textAlign as CanvasTextAlign;
|
context.textAlign = element.textAlign as CanvasTextAlign;
|
||||||
|
|
||||||
// Canvas does not support multiline text by default
|
// Canvas does not support multiline text by default
|
||||||
@@ -759,12 +742,17 @@ export const renderElement = (
|
|||||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||||
|
|
||||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
context.strokeStyle =
|
||||||
|
appState.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
||||||
|
: FRAME_STYLE.strokeColor;
|
||||||
|
|
||||||
// TODO change later to only affect AI frames
|
// TODO change later to only affect AI frames
|
||||||
if (isMagicFrameElement(element)) {
|
if (isMagicFrameElement(element)) {
|
||||||
context.strokeStyle =
|
context.strokeStyle =
|
||||||
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
|
appState.theme === THEME.LIGHT
|
||||||
|
? "#7affd7"
|
||||||
|
: applyDarkModeFilter("#1d8264");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FRAME_STYLE.radius && context.roundRect) {
|
if (FRAME_STYLE.radius && context.roundRect) {
|
||||||
@@ -787,11 +775,6 @@ export const renderElement = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
// TODO investigate if we can do this in situ. Right now we need to call
|
|
||||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
|
||||||
// rely on existing shapes
|
|
||||||
ShapeCache.generateElementShape(element, null);
|
|
||||||
|
|
||||||
if (renderConfig.isExporting) {
|
if (renderConfig.isExporting) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||||
@@ -835,10 +818,6 @@ export const renderElement = (
|
|||||||
case "text":
|
case "text":
|
||||||
case "iframe":
|
case "iframe":
|
||||||
case "embeddable": {
|
case "embeddable": {
|
||||||
// TODO investigate if we can do this in situ. Right now we need to call
|
|
||||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
|
||||||
// rely on existing shapes
|
|
||||||
ShapeCache.generateElementShape(element, renderConfig);
|
|
||||||
if (renderConfig.isExporting) {
|
if (renderConfig.isExporting) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||||
@@ -861,9 +840,6 @@ export const renderElement = (
|
|||||||
context.save();
|
context.save();
|
||||||
context.translate(cx, cy);
|
context.translate(cx, cy);
|
||||||
|
|
||||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
|
||||||
context.filter = "none";
|
|
||||||
}
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
if (isArrowElement(element) && boundTextElement) {
|
if (isArrowElement(element) && boundTextElement) {
|
||||||
@@ -1026,23 +1002,6 @@ export const renderElement = (
|
|||||||
context.globalAlpha = 1;
|
context.globalAlpha = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
|
|
||||||
|
|
||||||
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
|
||||||
const svgPathData = getFreeDrawSvgPath(element);
|
|
||||||
const path = new Path2D(svgPathData);
|
|
||||||
pathsCache.set(element, path);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
|
||||||
return pathsCache.get(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
|
||||||
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFreedrawOutlineAsSegments(
|
export function getFreedrawOutlineAsSegments(
|
||||||
element: ExcalidrawFreeDrawElement,
|
element: ExcalidrawFreeDrawElement,
|
||||||
points: [number, number][],
|
points: [number, number][],
|
||||||
@@ -1098,57 +1057,3 @@ export function getFreedrawOutlineAsSegments(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
|
||||||
// If input points are empty (should they ever be?) return a dot
|
|
||||||
const inputPoints = element.simulatePressure
|
|
||||||
? element.points
|
|
||||||
: element.points.length
|
|
||||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
|
||||||
: [[0, 0, 0.5]];
|
|
||||||
|
|
||||||
// Consider changing the options for simulated pressure vs real pressure
|
|
||||||
const options: StrokeOptions = {
|
|
||||||
simulatePressure: element.simulatePressure,
|
|
||||||
size: element.strokeWidth * 4.25,
|
|
||||||
thinning: 0.6,
|
|
||||||
smoothing: 0.5,
|
|
||||||
streamline: 0.5,
|
|
||||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
|
||||||
last: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return getStroke(inputPoints as number[][], options) as [number, number][];
|
|
||||||
}
|
|
||||||
|
|
||||||
function med(A: number[], B: number[]) {
|
|
||||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim SVG path data so number are each two decimal points. This
|
|
||||||
// improves SVG exports, and prevents rendering errors on points
|
|
||||||
// with long decimals.
|
|
||||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
|
||||||
|
|
||||||
function getSvgPathFromStroke(points: number[][]): string {
|
|
||||||
if (!points.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = points.length - 1;
|
|
||||||
|
|
||||||
return points
|
|
||||||
.reduce(
|
|
||||||
(acc, point, i, arr) => {
|
|
||||||
if (i === max) {
|
|
||||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
|
||||||
} else {
|
|
||||||
acc.push(point, med(point, arr[i + 1]));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
["M", points[0], "Q"],
|
|
||||||
)
|
|
||||||
.join(" ")
|
|
||||||
.replace(TO_FIXED_PRECISION, "$1");
|
|
||||||
}
|
|
||||||
|
|||||||
+153
-43
@@ -1,4 +1,5 @@
|
|||||||
import { simplify } from "points-on-curve";
|
import { simplify } from "points-on-curve";
|
||||||
|
import { getStroke } from "perfect-freehand";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type GeometricShape,
|
type GeometricShape,
|
||||||
@@ -17,10 +18,12 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import {
|
import {
|
||||||
ROUGHNESS,
|
ROUGHNESS,
|
||||||
|
THEME,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
assertNever,
|
assertNever,
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
|
applyDarkModeFilter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
@@ -36,6 +39,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ElementShape,
|
ElementShape,
|
||||||
ElementShapes,
|
ElementShapes,
|
||||||
|
SVGPathString,
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
import { elementWithCanvasCache } from "./renderElement";
|
import { elementWithCanvasCache } from "./renderElement";
|
||||||
@@ -52,7 +56,6 @@ import { getCornerRadius, isPathALoop } from "./utils";
|
|||||||
import { headingForPointIsHorizontal } from "./heading";
|
import { headingForPointIsHorizontal } from "./heading";
|
||||||
|
|
||||||
import { canChangeRoundness } from "./comparisons";
|
import { canChangeRoundness } from "./comparisons";
|
||||||
import { generateFreeDrawShape } from "./renderElement";
|
|
||||||
import {
|
import {
|
||||||
getArrowheadPoints,
|
getArrowheadPoints,
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
@@ -60,6 +63,7 @@ import {
|
|||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { shouldTestInside } from "./collision";
|
import { shouldTestInside } from "./collision";
|
||||||
|
import { generateFreeDrawOvoidSvgPath } from "./freedraw";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -75,31 +79,38 @@ import type {
|
|||||||
import type { Drawable, Options } from "roughjs/bin/core";
|
import type { Drawable, Options } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
|
||||||
|
// Toggle between old (perfect-freehand) and new (ovoid-union) freedraw rendering
|
||||||
|
// Set to true to use the new ovoid-based implementation
|
||||||
|
export const USE_NEW_FREEDRAW_RENDERER = true;
|
||||||
|
|
||||||
export class ShapeCache {
|
export class ShapeCache {
|
||||||
private static rg = new RoughGenerator();
|
private static rg = new RoughGenerator();
|
||||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
private static cache = new WeakMap<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{ shape: ElementShape; theme: AppState["theme"] }
|
||||||
|
>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves shape from cache if available. Use this only if shape
|
* Retrieves shape from cache if available. Use this only if shape
|
||||||
* is optional and you have a fallback in case it's not cached.
|
* is optional and you have a fallback in case it's not cached.
|
||||||
*/
|
*/
|
||||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
public static get = <T extends ExcalidrawElement>(
|
||||||
return ShapeCache.cache.get(
|
element: T,
|
||||||
element,
|
theme: AppState["theme"] | null,
|
||||||
) as T["type"] extends keyof ElementShapes
|
) => {
|
||||||
? ElementShapes[T["type"]] | undefined
|
const cached = ShapeCache.cache.get(element);
|
||||||
: ElementShape | undefined;
|
if (cached && (theme === null || cached.theme === theme)) {
|
||||||
|
return cached.shape as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]] | undefined
|
||||||
|
: ElementShape | undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
public static set = <T extends ExcalidrawElement>(
|
public static delete = (element: ExcalidrawElement) => {
|
||||||
element: T,
|
|
||||||
shape: T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable,
|
|
||||||
) => ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
public static delete = (element: ExcalidrawElement) =>
|
|
||||||
ShapeCache.cache.delete(element);
|
ShapeCache.cache.delete(element);
|
||||||
|
elementWithCanvasCache.delete(element);
|
||||||
|
};
|
||||||
|
|
||||||
public static destroy = () => {
|
public static destroy = () => {
|
||||||
ShapeCache.cache = new WeakMap();
|
ShapeCache.cache = new WeakMap();
|
||||||
@@ -117,12 +128,13 @@ export class ShapeCache {
|
|||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
|
theme: AppState["theme"];
|
||||||
} | null,
|
} | null,
|
||||||
) => {
|
) => {
|
||||||
// when exporting, always regenerated to guarantee the latest shape
|
// when exporting, always regenerated to guarantee the latest shape
|
||||||
const cachedShape = renderConfig?.isExporting
|
const cachedShape = renderConfig?.isExporting
|
||||||
? undefined
|
? undefined
|
||||||
: ShapeCache.get(element);
|
: ShapeCache.get(element, renderConfig ? renderConfig.theme : null);
|
||||||
|
|
||||||
// `null` indicates no rc shape applicable for this element type,
|
// `null` indicates no rc shape applicable for this element type,
|
||||||
// but it's considered a valid cache value (= do not regenerate)
|
// but it's considered a valid cache value (= do not regenerate)
|
||||||
@@ -132,19 +144,25 @@ export class ShapeCache {
|
|||||||
|
|
||||||
elementWithCanvasCache.delete(element);
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
const shape = generateElementShape(
|
const shape = _generateElementShape(
|
||||||
element,
|
element,
|
||||||
ShapeCache.rg,
|
ShapeCache.rg,
|
||||||
renderConfig || {
|
renderConfig || {
|
||||||
isExporting: false,
|
isExporting: false,
|
||||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||||
embedsValidationStatus: null,
|
embedsValidationStatus: null,
|
||||||
|
theme: THEME.LIGHT,
|
||||||
},
|
},
|
||||||
) as T["type"] extends keyof ElementShapes
|
) as T["type"] extends keyof ElementShapes
|
||||||
? ElementShapes[T["type"]]
|
? ElementShapes[T["type"]]
|
||||||
: Drawable | null;
|
: Drawable | null;
|
||||||
|
|
||||||
ShapeCache.cache.set(element, shape);
|
if (!renderConfig?.isExporting) {
|
||||||
|
ShapeCache.cache.set(element, {
|
||||||
|
shape,
|
||||||
|
theme: renderConfig?.theme || THEME.LIGHT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return shape;
|
return shape;
|
||||||
};
|
};
|
||||||
@@ -180,6 +198,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
|
|||||||
export const generateRoughOptions = (
|
export const generateRoughOptions = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
continuousPath = false,
|
continuousPath = false,
|
||||||
|
isDarkMode: boolean = false,
|
||||||
): Options => {
|
): Options => {
|
||||||
const options: Options = {
|
const options: Options = {
|
||||||
seed: element.seed,
|
seed: element.seed,
|
||||||
@@ -204,7 +223,9 @@ export const generateRoughOptions = (
|
|||||||
fillWeight: element.strokeWidth / 2,
|
fillWeight: element.strokeWidth / 2,
|
||||||
hachureGap: element.strokeWidth * 4,
|
hachureGap: element.strokeWidth * 4,
|
||||||
roughness: adjustRoughness(element),
|
roughness: adjustRoughness(element),
|
||||||
stroke: element.strokeColor,
|
stroke: isDarkMode
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor,
|
||||||
preserveVertices:
|
preserveVertices:
|
||||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||||
};
|
};
|
||||||
@@ -218,6 +239,8 @@ export const generateRoughOptions = (
|
|||||||
options.fillStyle = element.fillStyle;
|
options.fillStyle = element.fillStyle;
|
||||||
options.fill = isTransparent(element.backgroundColor)
|
options.fill = isTransparent(element.backgroundColor)
|
||||||
? undefined
|
? undefined
|
||||||
|
: isDarkMode
|
||||||
|
? applyDarkModeFilter(element.backgroundColor)
|
||||||
: element.backgroundColor;
|
: element.backgroundColor;
|
||||||
if (element.type === "ellipse") {
|
if (element.type === "ellipse") {
|
||||||
options.curveFitting = 1;
|
options.curveFitting = 1;
|
||||||
@@ -231,6 +254,8 @@ export const generateRoughOptions = (
|
|||||||
options.fill =
|
options.fill =
|
||||||
element.backgroundColor === "transparent"
|
element.backgroundColor === "transparent"
|
||||||
? undefined
|
? undefined
|
||||||
|
: isDarkMode
|
||||||
|
? applyDarkModeFilter(element.backgroundColor)
|
||||||
: element.backgroundColor;
|
: element.backgroundColor;
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
@@ -284,6 +309,7 @@ const getArrowheadShapes = (
|
|||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
options: Options,
|
options: Options,
|
||||||
canvasBackgroundColor: string,
|
canvasBackgroundColor: string,
|
||||||
|
isDarkMode: boolean,
|
||||||
) => {
|
) => {
|
||||||
const arrowheadPoints = getArrowheadPoints(
|
const arrowheadPoints = getArrowheadPoints(
|
||||||
element,
|
element,
|
||||||
@@ -309,6 +335,10 @@ const getArrowheadShapes = (
|
|||||||
return [generator.line(x3, y3, x4, y4, options)];
|
return [generator.line(x3, y3, x4, y4, options)];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const strokeColor = isDarkMode
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor;
|
||||||
|
|
||||||
switch (arrowhead) {
|
switch (arrowhead) {
|
||||||
case "dot":
|
case "dot":
|
||||||
case "circle":
|
case "circle":
|
||||||
@@ -324,10 +354,10 @@ const getArrowheadShapes = (
|
|||||||
fill:
|
fill:
|
||||||
arrowhead === "circle_outline"
|
arrowhead === "circle_outline"
|
||||||
? canvasBackgroundColor
|
? canvasBackgroundColor
|
||||||
: element.strokeColor,
|
: strokeColor,
|
||||||
|
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
stroke: element.strokeColor,
|
stroke: strokeColor,
|
||||||
roughness: Math.min(0.5, options.roughness || 0),
|
roughness: Math.min(0.5, options.roughness || 0),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@@ -352,7 +382,7 @@ const getArrowheadShapes = (
|
|||||||
fill:
|
fill:
|
||||||
arrowhead === "triangle_outline"
|
arrowhead === "triangle_outline"
|
||||||
? canvasBackgroundColor
|
? canvasBackgroundColor
|
||||||
: element.strokeColor,
|
: strokeColor,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
roughness: Math.min(1, options.roughness || 0),
|
roughness: Math.min(1, options.roughness || 0),
|
||||||
},
|
},
|
||||||
@@ -380,7 +410,7 @@ const getArrowheadShapes = (
|
|||||||
fill:
|
fill:
|
||||||
arrowhead === "diamond_outline"
|
arrowhead === "diamond_outline"
|
||||||
? canvasBackgroundColor
|
? canvasBackgroundColor
|
||||||
: element.strokeColor,
|
: strokeColor,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
roughness: Math.min(1, options.roughness || 0),
|
roughness: Math.min(1, options.roughness || 0),
|
||||||
},
|
},
|
||||||
@@ -602,19 +632,22 @@ export const generateLinearCollisionShape = (
|
|||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const generateElementShape = (
|
const _generateElementShape = (
|
||||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
{
|
{
|
||||||
isExporting,
|
isExporting,
|
||||||
canvasBackgroundColor,
|
canvasBackgroundColor,
|
||||||
embedsValidationStatus,
|
embedsValidationStatus,
|
||||||
|
theme,
|
||||||
}: {
|
}: {
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
canvasBackgroundColor: string;
|
canvasBackgroundColor: string;
|
||||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||||
|
theme?: AppState["theme"];
|
||||||
},
|
},
|
||||||
): Drawable | Drawable[] | null => {
|
): ElementShape => {
|
||||||
|
const isDarkMode = theme === THEME.DARK;
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "iframe":
|
case "iframe":
|
||||||
@@ -640,6 +673,7 @@ const generateElementShape = (
|
|||||||
embedsValidationStatus,
|
embedsValidationStatus,
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
|
isDarkMode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -655,6 +689,7 @@ const generateElementShape = (
|
|||||||
embedsValidationStatus,
|
embedsValidationStatus,
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
|
isDarkMode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -692,7 +727,7 @@ const generateElementShape = (
|
|||||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||||
topY + horizontalRadius
|
topY + horizontalRadius
|
||||||
}`,
|
}`,
|
||||||
generateRoughOptions(element, true),
|
generateRoughOptions(element, true, isDarkMode),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
shape = generator.polygon(
|
shape = generator.polygon(
|
||||||
@@ -702,7 +737,7 @@ const generateElementShape = (
|
|||||||
[bottomX, bottomY],
|
[bottomX, bottomY],
|
||||||
[leftX, leftY],
|
[leftX, leftY],
|
||||||
],
|
],
|
||||||
generateRoughOptions(element),
|
generateRoughOptions(element, false, isDarkMode),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return shape;
|
return shape;
|
||||||
@@ -713,14 +748,14 @@ const generateElementShape = (
|
|||||||
element.height / 2,
|
element.height / 2,
|
||||||
element.width,
|
element.width,
|
||||||
element.height,
|
element.height,
|
||||||
generateRoughOptions(element),
|
generateRoughOptions(element, false, isDarkMode),
|
||||||
);
|
);
|
||||||
return shape;
|
return shape;
|
||||||
}
|
}
|
||||||
case "line":
|
case "line":
|
||||||
case "arrow": {
|
case "arrow": {
|
||||||
let shape: ElementShapes[typeof element.type];
|
let shape: ElementShapes[typeof element.type];
|
||||||
const options = generateRoughOptions(element);
|
const options = generateRoughOptions(element, false, isDarkMode);
|
||||||
|
|
||||||
// points array can be empty in the beginning, so it is important to add
|
// points array can be empty in the beginning, so it is important to add
|
||||||
// initial position to it
|
// initial position to it
|
||||||
@@ -745,7 +780,7 @@ const generateElementShape = (
|
|||||||
shape = [
|
shape = [
|
||||||
generator.path(
|
generator.path(
|
||||||
generateElbowArrowShape(points, 16),
|
generateElbowArrowShape(points, 16),
|
||||||
generateRoughOptions(element, true),
|
generateRoughOptions(element, true, isDarkMode),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -778,6 +813,7 @@ const generateElementShape = (
|
|||||||
generator,
|
generator,
|
||||||
options,
|
options,
|
||||||
canvasBackgroundColor,
|
canvasBackgroundColor,
|
||||||
|
isDarkMode,
|
||||||
);
|
);
|
||||||
shape.push(...shapes);
|
shape.push(...shapes);
|
||||||
}
|
}
|
||||||
@@ -795,6 +831,7 @@ const generateElementShape = (
|
|||||||
generator,
|
generator,
|
||||||
options,
|
options,
|
||||||
canvasBackgroundColor,
|
canvasBackgroundColor,
|
||||||
|
isDarkMode,
|
||||||
);
|
);
|
||||||
shape.push(...shapes);
|
shape.push(...shapes);
|
||||||
}
|
}
|
||||||
@@ -802,23 +839,32 @@ const generateElementShape = (
|
|||||||
return shape;
|
return shape;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
let shape: ElementShapes[typeof element.type];
|
// oredered in terms of z-index [background, stroke]
|
||||||
generateFreeDrawShape(element);
|
const shapes: ElementShapes[typeof element.type] = [];
|
||||||
|
|
||||||
|
// (1) background fill (rc shape), optional
|
||||||
if (isPathALoop(element.points)) {
|
if (isPathALoop(element.points)) {
|
||||||
// generate rough polygon to fill freedraw shape
|
// generate rough polygon to fill freedraw shape
|
||||||
const simplifiedPoints = simplify(
|
const simplifiedPoints = simplify(
|
||||||
element.points as Mutable<LocalPoint[]>,
|
element.points as Mutable<LocalPoint[]>,
|
||||||
0.75,
|
0.75,
|
||||||
);
|
);
|
||||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
shapes.push(
|
||||||
...generateRoughOptions(element),
|
generator.curve(simplifiedPoints as [number, number][], {
|
||||||
stroke: "none",
|
...generateRoughOptions(element, false, isDarkMode),
|
||||||
});
|
stroke: "none",
|
||||||
} else {
|
}),
|
||||||
shape = null;
|
);
|
||||||
}
|
}
|
||||||
return shape;
|
|
||||||
|
// (2) stroke - use new ovoid renderer or legacy perfect-freehand
|
||||||
|
if (USE_NEW_FREEDRAW_RENDERER) {
|
||||||
|
shapes.push(generateFreeDrawOvoidSvgPath(element) as SVGPathString);
|
||||||
|
} else {
|
||||||
|
shapes.push(getFreeDrawSvgPath(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
return shapes;
|
||||||
}
|
}
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
@@ -925,9 +971,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
return getPolygonShape(element);
|
return getPolygonShape(element);
|
||||||
case "arrow":
|
case "arrow":
|
||||||
case "line": {
|
case "line": {
|
||||||
const roughShape =
|
const roughShape = ShapeCache.generateElementShape(element, null)[0];
|
||||||
ShapeCache.get(element)?.[0] ??
|
|
||||||
ShapeCache.generateElementShape(element, null)[0];
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
return shouldTestInside(element)
|
return shouldTestInside(element)
|
||||||
@@ -1003,3 +1047,69 @@ export const toggleLinePolygonState = (
|
|||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// freedraw shape helper
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// NOTE not cached (-> for SVG export)
|
||||||
|
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
|
||||||
|
return getSvgPathFromStroke(
|
||||||
|
getFreedrawOutlinePoints(element),
|
||||||
|
) as SVGPathString;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFreedrawOutlinePoints = (
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
) => {
|
||||||
|
// If input points are empty (should they ever be?) return a dot
|
||||||
|
const inputPoints = element.simulatePressure
|
||||||
|
? element.points
|
||||||
|
: element.points.length
|
||||||
|
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||||
|
: [[0, 0, 0.5]];
|
||||||
|
|
||||||
|
return getStroke(inputPoints as number[][], {
|
||||||
|
simulatePressure: element.simulatePressure,
|
||||||
|
size: element.strokeWidth * 4.25,
|
||||||
|
thinning: 0.6,
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.5,
|
||||||
|
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||||
|
last: true,
|
||||||
|
}) as [number, number][];
|
||||||
|
};
|
||||||
|
|
||||||
|
const med = (A: number[], B: number[]) => {
|
||||||
|
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trim SVG path data so number are each two decimal points. This
|
||||||
|
// improves SVG exports, and prevents rendering errors on points
|
||||||
|
// with long decimals.
|
||||||
|
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||||
|
|
||||||
|
const getSvgPathFromStroke = (points: number[][]): string => {
|
||||||
|
if (!points.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = points.length - 1;
|
||||||
|
|
||||||
|
return points
|
||||||
|
.reduce(
|
||||||
|
(acc, point, i, arr) => {
|
||||||
|
if (i === max) {
|
||||||
|
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||||
|
} else {
|
||||||
|
acc.push(point, med(point, arr[i + 1]));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
["M", points[0], "Q"],
|
||||||
|
)
|
||||||
|
.join(" ")
|
||||||
|
.replace(TO_FIXED_PRECISION, "$1");
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
TAP_TWICE_TIMEOUT,
|
TAP_TWICE_TIMEOUT,
|
||||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||||
THEME,
|
THEME,
|
||||||
THEME_FILTER,
|
|
||||||
TOUCH_CTX_MENU_TIMEOUT,
|
TOUCH_CTX_MENU_TIMEOUT,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
YOUTUBE_STATES,
|
YOUTUBE_STATES,
|
||||||
@@ -89,6 +88,7 @@ import {
|
|||||||
getDateTime,
|
getDateTime,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
|
applyDarkModeFilter,
|
||||||
type EXPORT_IMAGE_TYPES,
|
type EXPORT_IMAGE_TYPES,
|
||||||
randomInteger,
|
randomInteger,
|
||||||
CLASSES,
|
CLASSES,
|
||||||
@@ -346,7 +346,7 @@ import {
|
|||||||
|
|
||||||
import { exportCanvas, loadFromBlob } from "../data";
|
import { exportCanvas, loadFromBlob } from "../data";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||||
import { restore, restoreElements } from "../data/restore";
|
import { restoreAppState, restoreElements } from "../data/restore";
|
||||||
import { getCenter, getDistance } from "../gesture";
|
import { getCenter, getDistance } from "../gesture";
|
||||||
import { History } from "../history";
|
import { History } from "../history";
|
||||||
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
|
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
|
||||||
@@ -1770,8 +1770,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: this.state.viewBackgroundColor,
|
background: isDarkTheme
|
||||||
filter: isDarkTheme ? THEME_FILTER : "none",
|
? applyDarkModeFilter(this.state.viewBackgroundColor)
|
||||||
|
: this.state.viewBackgroundColor,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
border: "none",
|
border: "none",
|
||||||
display: "block",
|
display: "block",
|
||||||
@@ -1781,7 +1782,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
fontFamily: "Assistant",
|
fontFamily: "Assistant",
|
||||||
fontSize: `${FRAME_STYLE.nameFontSize}px`,
|
fontSize: `${FRAME_STYLE.nameFontSize}px`,
|
||||||
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
|
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
|
||||||
color: "var(--color-gray-80)",
|
color: isDarkTheme
|
||||||
|
? FRAME_STYLE.nameColorDarkTheme
|
||||||
|
: FRAME_STYLE.nameColorLightTheme,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
maxWidth: `${
|
maxWidth: `${
|
||||||
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
|
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
|
||||||
@@ -2116,6 +2119,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elementsPendingErasure: this.elementsPendingErasure,
|
elementsPendingErasure: this.elementsPendingErasure,
|
||||||
pendingFlowchartNodes:
|
pendingFlowchartNodes:
|
||||||
this.flowChartCreator.pendingNodes,
|
this.flowChartCreator.pendingNodes,
|
||||||
|
theme: this.state.theme,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{this.state.newElement && (
|
{this.state.newElement && (
|
||||||
@@ -2136,6 +2140,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elementsPendingErasure:
|
elementsPendingErasure:
|
||||||
this.elementsPendingErasure,
|
this.elementsPendingErasure,
|
||||||
pendingFlowchartNodes: null,
|
pendingFlowchartNodes: null,
|
||||||
|
theme: this.state.theme,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2701,46 +2706,47 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const scene = restore(initialData, null, null, {
|
const restoredElements = restoreElements(initialData?.elements, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
deleteInvisibleElements: true,
|
deleteInvisibleElements: true,
|
||||||
});
|
});
|
||||||
const activeTool = scene.appState.activeTool;
|
let restoredAppState = restoreAppState(initialData?.appState, null);
|
||||||
|
const activeTool = restoredAppState.activeTool;
|
||||||
|
|
||||||
if (!scene.appState.preferredSelectionTool.initialized) {
|
if (!restoredAppState.preferredSelectionTool.initialized) {
|
||||||
scene.appState.preferredSelectionTool = {
|
restoredAppState.preferredSelectionTool = {
|
||||||
type:
|
type:
|
||||||
this.editorInterface.formFactor === "phone" ? "lasso" : "selection",
|
this.editorInterface.formFactor === "phone" ? "lasso" : "selection",
|
||||||
initialized: true,
|
initialized: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.appState = {
|
restoredAppState = {
|
||||||
...scene.appState,
|
...restoredAppState,
|
||||||
theme: this.props.theme || scene.appState.theme,
|
theme: this.props.theme || restoredAppState.theme,
|
||||||
// we're falling back to current (pre-init) state when deciding
|
// we're falling back to current (pre-init) state when deciding
|
||||||
// whether to open the library, to handle a case where we
|
// whether to open the library, to handle a case where we
|
||||||
// update the state outside of initialData (e.g. when loading the app
|
// update the state outside of initialData (e.g. when loading the app
|
||||||
// with a library install link, which should auto-open the library)
|
// with a library install link, which should auto-open the library)
|
||||||
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
|
openSidebar: restoredAppState?.openSidebar || this.state.openSidebar,
|
||||||
activeTool:
|
activeTool:
|
||||||
activeTool.type === "image" ||
|
activeTool.type === "image" ||
|
||||||
activeTool.type === "lasso" ||
|
activeTool.type === "lasso" ||
|
||||||
activeTool.type === "selection"
|
activeTool.type === "selection"
|
||||||
? {
|
? {
|
||||||
...activeTool,
|
...activeTool,
|
||||||
type: scene.appState.preferredSelectionTool.type,
|
type: restoredAppState.preferredSelectionTool.type,
|
||||||
}
|
}
|
||||||
: scene.appState.activeTool,
|
: restoredAppState.activeTool,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
toast: this.state.toast,
|
toast: this.state.toast,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initialData?.scrollToContent) {
|
if (initialData?.scrollToContent) {
|
||||||
scene.appState = {
|
restoredAppState = {
|
||||||
...scene.appState,
|
...restoredAppState,
|
||||||
...calculateScrollCenter(scene.elements, {
|
...calculateScrollCenter(restoredElements, {
|
||||||
...scene.appState,
|
...restoredAppState,
|
||||||
width: this.state.width,
|
width: this.state.width,
|
||||||
height: this.state.height,
|
height: this.state.height,
|
||||||
offsetTop: this.state.offsetTop,
|
offsetTop: this.state.offsetTop,
|
||||||
@@ -2752,7 +2758,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.resetStore();
|
this.resetStore();
|
||||||
this.resetHistory();
|
this.resetHistory();
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
...scene,
|
elements: restoredElements,
|
||||||
|
appState: restoredAppState,
|
||||||
|
files: initialData?.files,
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3178,6 +3186,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
) {
|
) {
|
||||||
setEraserCursor(this.interactiveCanvas, this.state.theme);
|
setEraserCursor(this.interactiveCanvas, this.state.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide hyperlink popup if shown when element type is not selection
|
// Hide hyperlink popup if shown when element type is not selection
|
||||||
if (
|
if (
|
||||||
prevState.activeTool.type === "selection" &&
|
prevState.activeTool.type === "selection" &&
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { KEYS } from "@excalidraw/common";
|
import { isTransparent, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import tinycolor from "tinycolor2";
|
||||||
|
|
||||||
import { getShortcutKey } from "../..//shortcut";
|
import { getShortcutKey } from "../..//shortcut";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
@@ -10,18 +12,32 @@ import { useEditorInterface } from "../App";
|
|||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { eyeDropperIcon } from "../icons";
|
import { eyeDropperIcon } from "../icons";
|
||||||
|
|
||||||
import { getColor } from "./ColorPicker";
|
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
|
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
|
|
||||||
interface ColorInputProps {
|
/**
|
||||||
color: string;
|
* tries to keep the input color as-is if it's valid, making minimal adjustments
|
||||||
onChange: (color: string) => void;
|
* (trimming whitespace or adding `#` to hex colors)
|
||||||
label: string;
|
*/
|
||||||
colorPickerType: ColorPickerType;
|
export const normalizeInputColor = (color: string): string | null => {
|
||||||
placeholder?: string;
|
color = color.trim();
|
||||||
}
|
if (isTransparent(color)) {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tc = tinycolor(color);
|
||||||
|
if (tc.isValid()) {
|
||||||
|
// testing for `#` first fixes a bug on Electron (more specfically, an
|
||||||
|
// Obsidian popout window), where a hex color without `#` is considered valid
|
||||||
|
if (tc.getFormat() === "hex" && !color.startsWith("#")) {
|
||||||
|
return `#${color}`;
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const ColorInput = ({
|
export const ColorInput = ({
|
||||||
color,
|
color,
|
||||||
@@ -29,7 +45,13 @@ export const ColorInput = ({
|
|||||||
label,
|
label,
|
||||||
colorPickerType,
|
colorPickerType,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: ColorInputProps) => {
|
}: {
|
||||||
|
color: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
label: string;
|
||||||
|
colorPickerType: ColorPickerType;
|
||||||
|
placeholder?: string;
|
||||||
|
}) => {
|
||||||
const editorInterface = useEditorInterface();
|
const editorInterface = useEditorInterface();
|
||||||
const [innerValue, setInnerValue] = useState(color);
|
const [innerValue, setInnerValue] = useState(color);
|
||||||
const [activeSection, setActiveColorPickerSection] = useAtom(
|
const [activeSection, setActiveColorPickerSection] = useAtom(
|
||||||
@@ -43,7 +65,7 @@ export const ColorInput = ({
|
|||||||
const changeColor = useCallback(
|
const changeColor = useCallback(
|
||||||
(inputValue: string) => {
|
(inputValue: string) => {
|
||||||
const value = inputValue.toLowerCase();
|
const value = inputValue.toLowerCase();
|
||||||
const color = getColor(value);
|
const color = normalizeInputColor(value);
|
||||||
|
|
||||||
if (color) {
|
if (color) {
|
||||||
onChange(color);
|
onChange(color);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useRef, useEffect } from "react";
|
|||||||
import {
|
import {
|
||||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
isTransparent,
|
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
@@ -38,27 +37,6 @@ import type { ColorPickerType } from "./colorPickerUtils";
|
|||||||
|
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
const isValidColor = (color: string) => {
|
|
||||||
const style = new Option().style;
|
|
||||||
style.color = color;
|
|
||||||
return !!style.color;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getColor = (color: string): string | null => {
|
|
||||||
if (isTransparent(color)) {
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// testing for `#` first fixes a bug on Electron (more specfically, an
|
|
||||||
// Obsidian popout window), where a hex color without `#` is (incorrectly)
|
|
||||||
// considered valid
|
|
||||||
return isValidColor(`#${color}`)
|
|
||||||
? `#${color}`
|
|
||||||
: isValidColor(color)
|
|
||||||
? color
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "@excalidraw/common";
|
import {
|
||||||
|
isTransparent,
|
||||||
|
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
|
||||||
|
tinycolor,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@@ -108,48 +112,17 @@ export const isColorDark = (color: string, threshold = 160): boolean => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (color === "transparent") {
|
if (isTransparent(color)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// a string color (white etc) or any other format -> convert to rgb by way
|
const tc = tinycolor(color);
|
||||||
// of creating a DOM node and retrieving the computeStyle
|
if (!tc.isValid()) {
|
||||||
if (!color.startsWith("#")) {
|
// invalid color -> assume it defaults to black
|
||||||
const node = document.createElement("div");
|
|
||||||
node.style.color = color;
|
|
||||||
|
|
||||||
if (node.style.color) {
|
|
||||||
// making invisible so document doesn't reflow (hopefully).
|
|
||||||
// display=none works too, but supposedly not in all browsers
|
|
||||||
node.style.position = "absolute";
|
|
||||||
node.style.visibility = "hidden";
|
|
||||||
node.style.width = "0";
|
|
||||||
node.style.height = "0";
|
|
||||||
|
|
||||||
// needs to be in DOM else browser won't compute the style
|
|
||||||
document.body.appendChild(node);
|
|
||||||
const computedColor = getComputedStyle(node).color;
|
|
||||||
document.body.removeChild(node);
|
|
||||||
// computed style is in rgb() format
|
|
||||||
const rgb = computedColor
|
|
||||||
.replace(/^(rgb|rgba)\(/, "")
|
|
||||||
.replace(/\)$/, "")
|
|
||||||
.replace(/\s/g, "")
|
|
||||||
.split(",");
|
|
||||||
const r = parseInt(rgb[0]);
|
|
||||||
const g = parseInt(rgb[1]);
|
|
||||||
const b = parseInt(rgb[2]);
|
|
||||||
|
|
||||||
return calculateContrast(r, g, b) < threshold;
|
|
||||||
}
|
|
||||||
// invalid color -> assume it default to black
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = parseInt(color.slice(1, 3), 16);
|
const { r, g, b } = tc.toRgb();
|
||||||
const g = parseInt(color.slice(3, 5), 16);
|
|
||||||
const b = parseInt(color.slice(5, 7), 16);
|
|
||||||
|
|
||||||
return calculateContrast(r, g, b) < threshold;
|
return calculateContrast(r, g, b) < threshold;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ import type { ActionManager } from "../actions/manager";
|
|||||||
|
|
||||||
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
|
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
|
||||||
|
|
||||||
const supportsContextFilters =
|
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
|
||||||
|
|
||||||
export const ErrorCanvasPreview = () => {
|
export const ErrorCanvasPreview = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -230,25 +227,23 @@ const ImageExportModal = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ExportSetting>
|
</ExportSetting>
|
||||||
{supportsContextFilters && (
|
<ExportSetting
|
||||||
<ExportSetting
|
label={t("imageExportDialog.label.darkMode")}
|
||||||
label={t("imageExportDialog.label.darkMode")}
|
name="exportDarkModeSwitch"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
name="exportDarkModeSwitch"
|
name="exportDarkModeSwitch"
|
||||||
>
|
checked={exportDarkMode}
|
||||||
<Switch
|
onChange={(checked) => {
|
||||||
name="exportDarkModeSwitch"
|
setExportDarkMode(checked);
|
||||||
checked={exportDarkMode}
|
actionManager.executeAction(
|
||||||
onChange={(checked) => {
|
actionExportWithDarkMode,
|
||||||
setExportDarkMode(checked);
|
"ui",
|
||||||
actionManager.executeAction(
|
checked,
|
||||||
actionExportWithDarkMode,
|
);
|
||||||
"ui",
|
}}
|
||||||
checked,
|
/>
|
||||||
);
|
</ExportSetting>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ExportSetting>
|
|
||||||
)}
|
|
||||||
<ExportSetting
|
<ExportSetting
|
||||||
label={t("imageExportDialog.label.embedScene")}
|
label={t("imageExportDialog.label.embedScene")}
|
||||||
tooltip={t("imageExportDialog.tooltip.embedScene")}
|
tooltip={t("imageExportDialog.tooltip.embedScene")}
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ export const convertMermaidToExcalidraw = async ({
|
|||||||
maxWidthOrHeight:
|
maxWidthOrHeight:
|
||||||
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
||||||
window.devicePixelRatio,
|
window.devicePixelRatio,
|
||||||
|
appState: {
|
||||||
|
// TODO hack (will be refactored in TTD v2)
|
||||||
|
exportWithDarkMode: document
|
||||||
|
.querySelector(".excalidraw-container")
|
||||||
|
?.classList.contains("theme--dark"),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ body.excalidraw-cursor-resize * {
|
|||||||
|
|
||||||
&.interactive {
|
&.interactive {
|
||||||
z-index: var(--zIndex-interactiveCanvas);
|
z-index: var(--zIndex-interactiveCanvas);
|
||||||
|
// Apply theme filter only to interactive canvas for UI elements
|
||||||
|
// (resize handles, selection boxes, etc.)
|
||||||
|
filter: var(--theme-filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the main canvas from document flow to avoid resizeObserver
|
// Remove the main canvas from document flow to avoid resizeObserver
|
||||||
@@ -134,16 +137,6 @@ body.excalidraw-cursor-resize * {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.theme--dark {
|
|
||||||
// The percentage is inspired by
|
|
||||||
// https://material.io/design/color/dark-theme.html#properties, which
|
|
||||||
// recommends surface color of #121212, 93% yields #111111 for #FFF
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
filter: var(--theme-filter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer {
|
.FixedSideContainer {
|
||||||
padding-top: var(--sat, 0);
|
padding-top: var(--sat, 0);
|
||||||
padding-right: var(--sar, 0);
|
padding-right: var(--sar, 0);
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import { decodeSvgBase64Payload } from "../scene/export";
|
|||||||
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
||||||
import { nativeFileSystemSupported } from "./filesystem";
|
import { nativeFileSystemSupported } from "./filesystem";
|
||||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
import { restore, restoreLibraryItems } from "./restore";
|
import {
|
||||||
|
restoreAppState,
|
||||||
|
restoreElements,
|
||||||
|
restoreLibraryItems,
|
||||||
|
} from "./restore";
|
||||||
|
|
||||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||||
|
|
||||||
@@ -155,10 +159,13 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
if (isValidExcalidrawData(data)) {
|
if (isValidExcalidrawData(data)) {
|
||||||
return {
|
return {
|
||||||
type: MIME_TYPES.excalidraw,
|
type: MIME_TYPES.excalidraw,
|
||||||
data: restore(
|
data: {
|
||||||
{
|
elements: restoreElements(data.elements, localElements, {
|
||||||
elements: data.elements || [],
|
repairBindings: true,
|
||||||
appState: {
|
deleteInvisibleElements: true,
|
||||||
|
}),
|
||||||
|
appState: restoreAppState(
|
||||||
|
{
|
||||||
theme: localAppState?.theme,
|
theme: localAppState?.theme,
|
||||||
fileHandle: fileHandle || blob.handle || null,
|
fileHandle: fileHandle || blob.handle || null,
|
||||||
...cleanAppStateForExport(data.appState || {}),
|
...cleanAppStateForExport(data.appState || {}),
|
||||||
@@ -166,16 +173,10 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
? calculateScrollCenter(data.elements || [], localAppState)
|
? calculateScrollCenter(data.elements || [], localAppState)
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
files: data.files,
|
localAppState,
|
||||||
},
|
),
|
||||||
localAppState,
|
files: data.files || {},
|
||||||
localElements,
|
},
|
||||||
{
|
|
||||||
repairBindings: true,
|
|
||||||
refreshDimensions: false,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
} else if (isValidLibrary(data)) {
|
} else if (isValidLibrary(data)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const shouldDiscardRemoteElement = (
|
|||||||
// resolve conflicting edits deterministically by taking the one with
|
// resolve conflicting edits deterministically by taking the one with
|
||||||
// the lowest versionNonce
|
// the lowest versionNonce
|
||||||
(local.version === remote.version &&
|
(local.version === remote.version &&
|
||||||
local.versionNonce < remote.versionNonce))
|
local.versionNonce <= remote.versionNonce))
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isFiniteNumber, pointFrom } from "@excalidraw/math";
|
import { isFiniteNumber, pointFrom } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type CombineBrandsIfNeeded,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
@@ -131,13 +132,18 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
|
|||||||
element: T,
|
element: T,
|
||||||
binding: FixedPointBinding | null,
|
binding: FixedPointBinding | null,
|
||||||
targetElementsMap: Readonly<ElementsMap>,
|
targetElementsMap: Readonly<ElementsMap>,
|
||||||
localElementsMap: Readonly<ElementsMap> | null | undefined,
|
/** used for context (arrow bindings) */
|
||||||
|
existingElementsMap: Readonly<ElementsMap> | null | undefined,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
): FixedPointBinding | null => {
|
): FixedPointBinding | null => {
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// elbow arrows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
const fixedPointBinding:
|
const fixedPointBinding:
|
||||||
| ExcalidrawElbowArrowElement["startBinding"]
|
| ExcalidrawElbowArrowElement["startBinding"]
|
||||||
@@ -150,24 +156,41 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
|
|||||||
return fixedPointBinding;
|
return fixedPointBinding;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if the bound element is missing but the binding is at least
|
// ---------------------------------------------------------------------------
|
||||||
// looking like a valid one shape-wise
|
// simple arrows
|
||||||
if (binding.mode && binding.fixedPoint && binding.elementId) {
|
// ---------------------------------------------------------------------------
|
||||||
return {
|
|
||||||
elementId: binding.elementId,
|
// binding schema v2
|
||||||
mode: binding.mode,
|
// ---------------------------------------------------------------------------
|
||||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]),
|
|
||||||
} as FixedPointBinding | null;
|
if (binding.mode) {
|
||||||
|
// if latest binding schema, don't check if binding.elementId exists
|
||||||
|
// (it's done in a separate pass)
|
||||||
|
if (binding.elementId) {
|
||||||
|
return {
|
||||||
|
elementId: binding.elementId,
|
||||||
|
mode: binding.mode,
|
||||||
|
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]),
|
||||||
|
} as FixedPointBinding | null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// binding schema v1 (legacy) -> attempt to migrate to v2
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const targetBoundElement =
|
const targetBoundElement =
|
||||||
(targetElementsMap.get(binding.elementId) as ExcalidrawBindableElement) ||
|
(targetElementsMap.get(binding.elementId) as ExcalidrawBindableElement) ||
|
||||||
undefined;
|
undefined;
|
||||||
const boundElement =
|
const boundElement =
|
||||||
targetBoundElement ||
|
targetBoundElement ||
|
||||||
(localElementsMap?.get(binding.elementId) as ExcalidrawBindableElement) ||
|
(existingElementsMap?.get(
|
||||||
|
binding.elementId,
|
||||||
|
) as ExcalidrawBindableElement) ||
|
||||||
undefined;
|
undefined;
|
||||||
const elementsMap = targetBoundElement ? targetElementsMap : localElementsMap;
|
const elementsMap = targetBoundElement
|
||||||
|
? targetElementsMap
|
||||||
|
: existingElementsMap;
|
||||||
|
|
||||||
// migrating legacy focus point bindings
|
// migrating legacy focus point bindings
|
||||||
if (boundElement && elementsMap) {
|
if (boundElement && elementsMap) {
|
||||||
@@ -296,9 +319,12 @@ const restoreElementWithProperties = <
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const restoreElement = (
|
export const restoreElement = (
|
||||||
|
/** element to be restored */
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
|
/** all elements to be restored */
|
||||||
targetElementsMap: Readonly<ElementsMap>,
|
targetElementsMap: Readonly<ElementsMap>,
|
||||||
localElementsMap: Readonly<ElementsMap> | null | undefined,
|
/** used for additional context */
|
||||||
|
existingElementsMap: Readonly<ElementsMap> | null | undefined,
|
||||||
opts?: {
|
opts?: {
|
||||||
deleteInvisibleElements?: boolean;
|
deleteInvisibleElements?: boolean;
|
||||||
},
|
},
|
||||||
@@ -420,14 +446,14 @@ export const restoreElement = (
|
|||||||
element as ExcalidrawArrowElement,
|
element as ExcalidrawArrowElement,
|
||||||
element.startBinding,
|
element.startBinding,
|
||||||
targetElementsMap,
|
targetElementsMap,
|
||||||
localElementsMap,
|
existingElementsMap,
|
||||||
"start",
|
"start",
|
||||||
),
|
),
|
||||||
endBinding: repairBinding(
|
endBinding: repairBinding(
|
||||||
element as ExcalidrawArrowElement,
|
element as ExcalidrawArrowElement,
|
||||||
element.endBinding,
|
element.endBinding,
|
||||||
targetElementsMap,
|
targetElementsMap,
|
||||||
localElementsMap,
|
existingElementsMap,
|
||||||
"end",
|
"end",
|
||||||
),
|
),
|
||||||
startArrowhead,
|
startArrowhead,
|
||||||
@@ -588,10 +614,10 @@ const repairFrameMembership = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restoreElements = (
|
export const restoreElements = <T extends ExcalidrawElement>(
|
||||||
targetElements: ImportedDataState["elements"],
|
targetElements: readonly T[] | undefined | null,
|
||||||
/** NOTE doesn't serve for reconciliation */
|
/** used for additional context (e.g. repairing arrow bindings) */
|
||||||
localElements: Readonly<ElementsMapOrArray> | null | undefined,
|
existingElements: Readonly<ElementsMapOrArray> | null | undefined,
|
||||||
opts?:
|
opts?:
|
||||||
| {
|
| {
|
||||||
refreshDimensions?: boolean;
|
refreshDimensions?: boolean;
|
||||||
@@ -599,11 +625,13 @@ export const restoreElements = (
|
|||||||
deleteInvisibleElements?: boolean;
|
deleteInvisibleElements?: boolean;
|
||||||
}
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
): OrderedExcalidrawElement[] => {
|
): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => {
|
||||||
// used to detect duplicate top-level element ids
|
// used to detect duplicate top-level element ids
|
||||||
const existingIds = new Set<string>();
|
const existingIds = new Set<string>();
|
||||||
const targetElementsMap = arrayToMap(targetElements || []);
|
const targetElementsMap = arrayToMap(targetElements || []);
|
||||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
const existingElementsMap = existingElements
|
||||||
|
? arrayToMap(existingElements)
|
||||||
|
: null;
|
||||||
const restoredElements = syncInvalidIndices(
|
const restoredElements = syncInvalidIndices(
|
||||||
(targetElements || []).reduce((elements, element) => {
|
(targetElements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
@@ -615,21 +643,18 @@ export const restoreElements = (
|
|||||||
let migratedElement: ExcalidrawElement | null = restoreElement(
|
let migratedElement: ExcalidrawElement | null = restoreElement(
|
||||||
element,
|
element,
|
||||||
targetElementsMap,
|
targetElementsMap,
|
||||||
localElementsMap,
|
existingElementsMap,
|
||||||
{
|
{
|
||||||
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (migratedElement) {
|
if (migratedElement) {
|
||||||
const localElement = localElementsMap?.get(element.id);
|
const localElement = existingElementsMap?.get(element.id);
|
||||||
|
|
||||||
const shouldMarkAsDeleted =
|
const shouldMarkAsDeleted =
|
||||||
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
||||||
|
|
||||||
if (
|
if (shouldMarkAsDeleted) {
|
||||||
shouldMarkAsDeleted ||
|
|
||||||
(localElement && localElement.version > migratedElement.version)
|
|
||||||
) {
|
|
||||||
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +675,10 @@ export const restoreElements = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!opts?.repairBindings) {
|
if (!opts?.repairBindings) {
|
||||||
return restoredElements;
|
return restoredElements as CombineBrandsIfNeeded<
|
||||||
|
T,
|
||||||
|
OrderedExcalidrawElement
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// repair binding. Mutates elements.
|
// repair binding. Mutates elements.
|
||||||
@@ -759,6 +787,41 @@ export const restoreElements = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}) as CombineBrandsIfNeeded<T, OrderedExcalidrawElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When replacing elements that may exist locally, this bumps their versions
|
||||||
|
* to the local version + 1. Mainly for later reconciliation to work properly.
|
||||||
|
*
|
||||||
|
* See https://github.com/excalidraw/excalidraw/issues/3795
|
||||||
|
*
|
||||||
|
* Generally use this on editor boundaries (importing from file etc.), though
|
||||||
|
* it does not apply universally (e.g. we don't want to do this for collab
|
||||||
|
* updates).
|
||||||
|
*/
|
||||||
|
export const bumpElementVersions = <T extends ExcalidrawElement>(
|
||||||
|
targetElements: readonly T[],
|
||||||
|
localElements: Readonly<ElementsMapOrArray> | null | undefined,
|
||||||
|
) => {
|
||||||
|
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||||
|
|
||||||
|
return targetElements.map((element) => {
|
||||||
|
const localElement = localElementsMap?.get(element.id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
localElement &&
|
||||||
|
(localElement.version > element.version ||
|
||||||
|
// same versions but different versionNonce means different edits
|
||||||
|
// (this often means the element was bumped during restore e.g. due
|
||||||
|
// to re-indexing, and the original element was modified elsewhere
|
||||||
|
// and supplied as localElements)
|
||||||
|
(localElement.version === element.version &&
|
||||||
|
localElement.versionNonce !== element.versionNonce))
|
||||||
|
) {
|
||||||
|
return bumpVersion(element, localElement.version);
|
||||||
|
}
|
||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -875,29 +938,6 @@ export const restoreAppState = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restore = (
|
|
||||||
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
|
|
||||||
/**
|
|
||||||
* Local AppState (`this.state` or initial state from localStorage) so that we
|
|
||||||
* don't overwrite local state with default values (when values not
|
|
||||||
* explicitly specified).
|
|
||||||
* Supply `null` if you can't get access to it.
|
|
||||||
*/
|
|
||||||
localAppState: Partial<AppState> | null | undefined,
|
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
|
||||||
elementsConfig?: {
|
|
||||||
refreshDimensions?: boolean;
|
|
||||||
repairBindings?: boolean;
|
|
||||||
deleteInvisibleElements?: boolean;
|
|
||||||
},
|
|
||||||
): RestoredDataState => {
|
|
||||||
return {
|
|
||||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
|
||||||
appState: restoreAppState(data?.appState, localAppState || null),
|
|
||||||
files: data?.files || {},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
||||||
const elements = restoreElements(
|
const elements = restoreElements(
|
||||||
getNonDeletedElements(libraryItem.elements),
|
getNonDeletedElements(libraryItem.elements),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
|
|||||||
export const libraryItemSvgsCache = atom<SvgCache>(new Map());
|
export const libraryItemSvgsCache = atom<SvgCache>(new Map());
|
||||||
|
|
||||||
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
|
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
|
||||||
|
// TODO should pass theme (appState.exportWithDark) - we're still using
|
||||||
|
// CSS filter here
|
||||||
return await exportToSvg({
|
return await exportToSvg({
|
||||||
elements,
|
elements,
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ export { isInvisiblySmallElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
export { defaultLang, useI18n, languages } from "./i18n";
|
export { defaultLang, useI18n, languages } from "./i18n";
|
||||||
export {
|
export {
|
||||||
restore,
|
|
||||||
restoreAppState,
|
restoreAppState,
|
||||||
restoreElement,
|
restoreElement,
|
||||||
restoreElements,
|
restoreElements,
|
||||||
@@ -251,7 +250,6 @@ export {
|
|||||||
loadSceneOrLibraryFromBlob,
|
loadSceneOrLibraryFromBlob,
|
||||||
loadLibraryFromBlob,
|
loadLibraryFromBlob,
|
||||||
} from "./data/blob";
|
} from "./data/blob";
|
||||||
export { getFreeDrawSvgPath } from "@excalidraw/element";
|
|
||||||
export { mergeLibraryItems, getLibraryItemsHash } from "./data/library";
|
export { mergeLibraryItems, getLibraryItemsHash } from "./data/library";
|
||||||
export { isLinearElement } from "@excalidraw/element";
|
export { isLinearElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
"multipleResults": "Ergebnisse",
|
"multipleResults": "Ergebnisse",
|
||||||
"placeholder": "Text auf Zeichenfläche suchen...",
|
"placeholder": "Text auf Zeichenfläche suchen...",
|
||||||
"frames": "",
|
"frames": "",
|
||||||
"texts": ""
|
"texts": "Texte"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
|
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
|
||||||
@@ -569,7 +569,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
"color": "",
|
"color": "Farbe",
|
||||||
"mostUsedCustomColors": "Beliebteste benutzerdefinierte Farben",
|
"mostUsedCustomColors": "Beliebteste benutzerdefinierte Farben",
|
||||||
"colors": "Farben",
|
"colors": "Farben",
|
||||||
"shades": "Schattierungen",
|
"shades": "Schattierungen",
|
||||||
@@ -651,15 +651,15 @@
|
|||||||
"shortcutHint": "Benutze {{shortcut}} für Befehlspalette"
|
"shortcutHint": "Benutze {{shortcut}} für Befehlspalette"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"ctrl": "",
|
"ctrl": "Strg",
|
||||||
"option": "",
|
"option": "",
|
||||||
"cmd": "",
|
"cmd": "",
|
||||||
"alt": "",
|
"alt": "",
|
||||||
"escape": "",
|
"escape": "",
|
||||||
"enter": "",
|
"enter": "",
|
||||||
"shift": "",
|
"shift": "",
|
||||||
"spacebar": "",
|
"spacebar": "Leertaste",
|
||||||
"delete": "",
|
"delete": "Löschen",
|
||||||
"mmb": ""
|
"mmb": "Mausrad"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
"editArrow": "Editar flecha"
|
"editArrow": "Editar flecha"
|
||||||
},
|
},
|
||||||
"polygon": {
|
"polygon": {
|
||||||
"breakPolygon": "",
|
"breakPolygon": "Abrir polígono",
|
||||||
"convertToPolygon": ""
|
"convertToPolygon": ""
|
||||||
},
|
},
|
||||||
"elementLock": {
|
"elementLock": {
|
||||||
@@ -651,7 +651,7 @@
|
|||||||
"shortcutHint": "Para la paleta de comandos, utilice {{shortcut}}"
|
"shortcutHint": "Para la paleta de comandos, utilice {{shortcut}}"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"ctrl": "",
|
"ctrl": "Control",
|
||||||
"option": "",
|
"option": "",
|
||||||
"cmd": "",
|
"cmd": "",
|
||||||
"alt": "",
|
"alt": "",
|
||||||
|
|||||||
@@ -341,7 +341,7 @@
|
|||||||
"canvasPanning": "Pour déplacer le canevas, maintenez {{shortcut_1}} ou {{shortcut_2}} enfoncé tout en faisant glisser, ou utilisez l'outil main",
|
"canvasPanning": "Pour déplacer le canevas, maintenez {{shortcut_1}} ou {{shortcut_2}} enfoncé tout en faisant glisser, ou utilisez l'outil main",
|
||||||
"linearElement": "Cliquez pour démarrer plusieurs points, faites glisser pour une seule ligne",
|
"linearElement": "Cliquez pour démarrer plusieurs points, faites glisser pour une seule ligne",
|
||||||
"arrowTool": "Cliquez pour démarrer plusieurs points, faites glisser pour une ligne unique. Appuyez à nouveau sur {{shortcut}} pour changer le type de flèche.",
|
"arrowTool": "Cliquez pour démarrer plusieurs points, faites glisser pour une ligne unique. Appuyez à nouveau sur {{shortcut}} pour changer le type de flèche.",
|
||||||
"arrowBindModifiers": "",
|
"arrowBindModifiers": "Maintenez {{shortcut_1}} pour désactiver la liaison, ou {{shortcut_2}} pour se lier à un point fixe",
|
||||||
"freeDraw": "Cliquez et faites glissez, relâchez quand vous avez terminé",
|
"freeDraw": "Cliquez et faites glissez, relâchez quand vous avez terminé",
|
||||||
"text": "Astuce : vous pouvez aussi ajouter du texte en double-cliquant n'importe où avec l'outil de sélection",
|
"text": "Astuce : vous pouvez aussi ajouter du texte en double-cliquant n'importe où avec l'outil de sélection",
|
||||||
"embeddable": "Cliquez et glissez pour créer une intégration de site web",
|
"embeddable": "Cliquez et glissez pour créer une intégration de site web",
|
||||||
|
|||||||
@@ -341,7 +341,7 @@
|
|||||||
"canvasPanning": "Per spostare la tela, tieni premuto {{shortcut_1}} o {{shortcut_2}} durante il trascinamento, oppure usa lo strumento della mano",
|
"canvasPanning": "Per spostare la tela, tieni premuto {{shortcut_1}} o {{shortcut_2}} durante il trascinamento, oppure usa lo strumento della mano",
|
||||||
"linearElement": "Clicca per iniziare una linea in più punti, trascina per singola linea",
|
"linearElement": "Clicca per iniziare una linea in più punti, trascina per singola linea",
|
||||||
"arrowTool": "Fai clic per iniziare più punti, trascina per una singola linea. Premi di nuovo {{shortcut}} per cambiare il tipo di freccia.",
|
"arrowTool": "Fai clic per iniziare più punti, trascina per una singola linea. Premi di nuovo {{shortcut}} per cambiare il tipo di freccia.",
|
||||||
"arrowBindModifiers": "",
|
"arrowBindModifiers": "Tieni premuto {{shortcut_1}} per disabilitare il binding, oppure {{shortcut_2}} per il binding a un punto fisso",
|
||||||
"freeDraw": "Clicca e trascina, rilascia quando avrai finito",
|
"freeDraw": "Clicca e trascina, rilascia quando avrai finito",
|
||||||
"text": "Suggerimento: puoi anche aggiungere del testo facendo doppio clic ovunque con lo strumento di selezione",
|
"text": "Suggerimento: puoi anche aggiungere del testo facendo doppio clic ovunque con lo strumento di selezione",
|
||||||
"embeddable": "Fare click e trascina per creare un incorporamento web",
|
"embeddable": "Fare click e trascina per creare un incorporamento web",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"copyAsPng": "Kopieer als PNG",
|
"copyAsPng": "Kopieer als PNG",
|
||||||
"copyAsSvg": "Kopieer naar klembord als SVG",
|
"copyAsSvg": "Kopieer naar klembord als SVG",
|
||||||
"copyText": "Kopieer naar klembord als tekst",
|
"copyText": "Kopieer naar klembord als tekst",
|
||||||
"copySource": "",
|
"copySource": "Bron kopiëren naar klembord",
|
||||||
"convertToCode": "Zet om naar code",
|
"convertToCode": "Zet om naar code",
|
||||||
"bringForward": "Breng naar voren",
|
"bringForward": "Breng naar voren",
|
||||||
"sendToBack": "Stuur naar achtergrond",
|
"sendToBack": "Stuur naar achtergrond",
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
"copyStyles": "Kopieer opmaak",
|
"copyStyles": "Kopieer opmaak",
|
||||||
"pasteStyles": "Plak opmaak",
|
"pasteStyles": "Plak opmaak",
|
||||||
"stroke": "Lijn",
|
"stroke": "Lijn",
|
||||||
"changeStroke": "",
|
"changeStroke": "Verander lijn kleur",
|
||||||
"background": "Achtergrond",
|
"background": "Achtergrond",
|
||||||
"changeBackground": "",
|
"changeBackground": "Verander achtergrond kleur",
|
||||||
"fill": "Invulling",
|
"fill": "Invulling",
|
||||||
"strokeWidth": "Lijnbreedte",
|
"strokeWidth": "Lijnbreedte",
|
||||||
"strokeStyle": "Lijnstijl",
|
"strokeStyle": "Lijnstijl",
|
||||||
@@ -43,17 +43,17 @@
|
|||||||
"arrowhead_circle": "Rond",
|
"arrowhead_circle": "Rond",
|
||||||
"arrowhead_circle_outline": "Rond (omtrek)",
|
"arrowhead_circle_outline": "Rond (omtrek)",
|
||||||
"arrowhead_triangle": "Driehoek",
|
"arrowhead_triangle": "Driehoek",
|
||||||
"arrowhead_triangle_outline": "",
|
"arrowhead_triangle_outline": "Driehoek (omtrek)",
|
||||||
"arrowhead_diamond": "",
|
"arrowhead_diamond": "Diamant",
|
||||||
"arrowhead_diamond_outline": "",
|
"arrowhead_diamond_outline": "Diamant (omtrek)",
|
||||||
"arrowhead_crowfoot_many": "",
|
"arrowhead_crowfoot_many": "Kraaienpoot (veel)",
|
||||||
"arrowhead_crowfoot_one": "",
|
"arrowhead_crowfoot_one": "Kraaienpoot (enkel)",
|
||||||
"arrowhead_crowfoot_one_or_many": "",
|
"arrowhead_crowfoot_one_or_many": "Kraaienpoot (enkel of veel)",
|
||||||
"more_options": "",
|
"more_options": "Meer opties",
|
||||||
"arrowtypes": "",
|
"arrowtypes": "Pijl type",
|
||||||
"arrowtype_sharp": "",
|
"arrowtype_sharp": "Scherpe pijl",
|
||||||
"arrowtype_round": "",
|
"arrowtype_round": "Gebogen pijl",
|
||||||
"arrowtype_elbowed": "",
|
"arrowtype_elbowed": "Hoek pijl",
|
||||||
"fontSize": "Tekstgrootte",
|
"fontSize": "Tekstgrootte",
|
||||||
"fontFamily": "Lettertype",
|
"fontFamily": "Lettertype",
|
||||||
"addWatermark": "Voeg \"Gemaakt met Excalidraw\" toe",
|
"addWatermark": "Voeg \"Gemaakt met Excalidraw\" toe",
|
||||||
@@ -61,12 +61,12 @@
|
|||||||
"normal": "Normaal",
|
"normal": "Normaal",
|
||||||
"code": "Code",
|
"code": "Code",
|
||||||
"small": "Klein",
|
"small": "Klein",
|
||||||
"medium": "",
|
"medium": "Middel",
|
||||||
"large": "Groot",
|
"large": "Groot",
|
||||||
"veryLarge": "Zeer groot",
|
"veryLarge": "Zeer groot",
|
||||||
"solid": "Ingekleurd",
|
"solid": "Ingekleurd",
|
||||||
"hachure": "Arcering",
|
"hachure": "Arcering",
|
||||||
"zigzag": "",
|
"zigzag": "Zigzag",
|
||||||
"crossHatch": "Tweemaal gearceerd",
|
"crossHatch": "Tweemaal gearceerd",
|
||||||
"thin": "Dun",
|
"thin": "Dun",
|
||||||
"bold": "Vet",
|
"bold": "Vet",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"canvasColors": "Gebruikt op canvas",
|
"canvasColors": "Gebruikt op canvas",
|
||||||
"canvasBackground": "Canvas achtergrond",
|
"canvasBackground": "Canvas achtergrond",
|
||||||
"drawingCanvas": "Canvas",
|
"drawingCanvas": "Canvas",
|
||||||
"clearCanvas": "",
|
"clearCanvas": "Canvas legen",
|
||||||
"layers": "Lagen",
|
"layers": "Lagen",
|
||||||
"actions": "Acties",
|
"actions": "Acties",
|
||||||
"language": "Taal",
|
"language": "Taal",
|
||||||
@@ -95,13 +95,13 @@
|
|||||||
"group": "Groeperen",
|
"group": "Groeperen",
|
||||||
"ungroup": "Groep opheffen",
|
"ungroup": "Groep opheffen",
|
||||||
"collaborators": "Deelnemers",
|
"collaborators": "Deelnemers",
|
||||||
"toggleGrid": "",
|
"toggleGrid": "Raster in-/uitschakelen",
|
||||||
"addToLibrary": "Voeg toe aan bibliotheek",
|
"addToLibrary": "Voeg toe aan bibliotheek",
|
||||||
"removeFromLibrary": "Verwijder uit bibliotheek",
|
"removeFromLibrary": "Verwijder uit bibliotheek",
|
||||||
"libraryLoadingMessage": "Bibliotheek laden…",
|
"libraryLoadingMessage": "Bibliotheek laden…",
|
||||||
"libraries": "Blader door bibliotheken",
|
"libraries": "Blader door bibliotheken",
|
||||||
"loadingScene": "Scène laden…",
|
"loadingScene": "Scène laden…",
|
||||||
"loadScene": "",
|
"loadScene": "Laad scène van uit bestand",
|
||||||
"align": "Uitlijnen",
|
"align": "Uitlijnen",
|
||||||
"alignTop": "Boven uitlijnen",
|
"alignTop": "Boven uitlijnen",
|
||||||
"alignBottom": "Onder uitlijnen",
|
"alignBottom": "Onder uitlijnen",
|
||||||
@@ -117,33 +117,33 @@
|
|||||||
"share": "Deel",
|
"share": "Deel",
|
||||||
"showStroke": "Toon lijn kleur kiezer",
|
"showStroke": "Toon lijn kleur kiezer",
|
||||||
"showBackground": "Toon achtergrondkleur kiezer",
|
"showBackground": "Toon achtergrondkleur kiezer",
|
||||||
"showFonts": "",
|
"showFonts": "Lettertype kiezer weergeven",
|
||||||
"toggleTheme": "",
|
"toggleTheme": "Licht-/donkerthema in-/uitschakelen",
|
||||||
"theme": "",
|
"theme": "Thema",
|
||||||
"personalLib": "Persoonlijke bibliotheek",
|
"personalLib": "Persoonlijke bibliotheek",
|
||||||
"excalidrawLib": "Excalidraw bibliotheek",
|
"excalidrawLib": "Excalidraw bibliotheek",
|
||||||
"decreaseFontSize": "Letters verkleinen",
|
"decreaseFontSize": "Letters verkleinen",
|
||||||
"increaseFontSize": "Letters vergroten",
|
"increaseFontSize": "Letters vergroten",
|
||||||
"unbindText": "Ontkoppel tekst",
|
"unbindText": "Ontkoppel tekst",
|
||||||
"bindText": "Koppel tekst aan de container",
|
"bindText": "Koppel tekst aan de container",
|
||||||
"createContainerFromText": "",
|
"createContainerFromText": "Tekst in een container laten doorlopen",
|
||||||
"link": {
|
"link": {
|
||||||
"edit": "Wijzig link",
|
"edit": "Wijzig link",
|
||||||
"editEmbed": "",
|
"editEmbed": "Embedbare link bewerken",
|
||||||
"create": "",
|
"create": "Link toevoegen",
|
||||||
"label": "Link",
|
"label": "Link",
|
||||||
"labelEmbed": "Link toevoegen & insluiten",
|
"labelEmbed": "Link toevoegen & embedden",
|
||||||
"empty": "Er is geen link ingesteld",
|
"empty": "Er is geen link ingesteld",
|
||||||
"hint": "",
|
"hint": "Typ of plak je link hier",
|
||||||
"goToElement": ""
|
"goToElement": "Ga naar doelelement"
|
||||||
},
|
},
|
||||||
"lineEditor": {
|
"lineEditor": {
|
||||||
"edit": "Bewerk regel",
|
"edit": "Bewerk regel",
|
||||||
"editArrow": ""
|
"editArrow": "Pijl bewerken"
|
||||||
},
|
},
|
||||||
"polygon": {
|
"polygon": {
|
||||||
"breakPolygon": "",
|
"breakPolygon": "Breek veelhoek",
|
||||||
"convertToPolygon": ""
|
"convertToPolygon": "Veranderen naar veelhoek"
|
||||||
},
|
},
|
||||||
"elementLock": {
|
"elementLock": {
|
||||||
"lock": "Vergrendel",
|
"lock": "Vergrendel",
|
||||||
@@ -153,50 +153,50 @@
|
|||||||
},
|
},
|
||||||
"statusPublished": "Gepubliceerd",
|
"statusPublished": "Gepubliceerd",
|
||||||
"sidebarLock": "Zijbalk open houden",
|
"sidebarLock": "Zijbalk open houden",
|
||||||
"selectAllElementsInFrame": "",
|
"selectAllElementsInFrame": "Selecteer alle elementen in het frame",
|
||||||
"removeAllElementsFromFrame": "",
|
"removeAllElementsFromFrame": "Verwijder alle elementen in het frame",
|
||||||
"eyeDropper": "",
|
"eyeDropper": "Kies kleur van canvas",
|
||||||
"textToDiagram": "",
|
"textToDiagram": "Tekst naar diagram",
|
||||||
"prompt": "",
|
"prompt": "Prompt",
|
||||||
"followUs": "Volg ons",
|
"followUs": "Volg ons",
|
||||||
"discordChat": "",
|
"discordChat": "Discord chat",
|
||||||
"zoomToFitViewport": "",
|
"zoomToFitViewport": "Zoom om in het venster te laten passen",
|
||||||
"zoomToFitSelection": "",
|
"zoomToFitSelection": "Zoom om in selectie te laten passen",
|
||||||
"zoomToFit": "",
|
"zoomToFit": "Zoom om alle element te laten passen",
|
||||||
"installPWA": "",
|
"installPWA": "Installeer Excalidraw lokaal (PWA)",
|
||||||
"autoResize": "",
|
"autoResize": "Automatisch aanpassen van tekst inschakelen",
|
||||||
"imageCropping": "",
|
"imageCropping": "Afbeelding bijsnijden",
|
||||||
"unCroppedDimension": "",
|
"unCroppedDimension": "Niet bijgesneden dimensie",
|
||||||
"copyElementLink": "",
|
"copyElementLink": "Kopieer link naar object",
|
||||||
"linkToElement": "",
|
"linkToElement": "Link naar object",
|
||||||
"wrapSelectionInFrame": "",
|
"wrapSelectionInFrame": "Selectie omslaan in frame",
|
||||||
"tab": "",
|
"tab": "Tab",
|
||||||
"shapeSwitch": ""
|
"shapeSwitch": "Verander vorm"
|
||||||
},
|
},
|
||||||
"elementLink": {
|
"elementLink": {
|
||||||
"title": "",
|
"title": "Link naar object",
|
||||||
"desc": "",
|
"desc": "Klik op een vorm op canvas of plak een link.",
|
||||||
"notFound": ""
|
"notFound": "Gekoppeld object is niet gevonden op canvas."
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"noItems": "Nog geen items toegevoegd...",
|
"noItems": "Nog geen items toegevoegd...",
|
||||||
"hint_emptyLibrary": "Selecteer een item op het canvas om het hier toe te voegen of installeer een bibliotheek uit de openbare repository, hieronder.",
|
"hint_emptyLibrary": "Selecteer een item op het canvas om het hier toe te voegen of installeer een bibliotheek uit de openbare repository, hieronder.",
|
||||||
"hint_emptyPrivateLibrary": "Selecteer een item op het canvas om het hier toe te voegen.",
|
"hint_emptyPrivateLibrary": "Selecteer een item op het canvas om het hier toe te voegen.",
|
||||||
"search": {
|
"search": {
|
||||||
"inputPlaceholder": "",
|
"inputPlaceholder": "Zoek in bibliotheek",
|
||||||
"heading": "",
|
"heading": "Bibliotheek overeenkomsten",
|
||||||
"noResults": "",
|
"noResults": "Geen items gevonden...",
|
||||||
"clearSearch": ""
|
"clearSearch": "Zoekopdracht wissen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"title": "",
|
"title": "Zoek op canvas",
|
||||||
"noMatch": "",
|
"noMatch": "Geen overeenkomsten gevonden...",
|
||||||
"singleResult": "",
|
"singleResult": "resultaat",
|
||||||
"multipleResults": "",
|
"multipleResults": "resultaten",
|
||||||
"placeholder": "",
|
"placeholder": "Zoek text op canvas...",
|
||||||
"frames": "",
|
"frames": "Frames",
|
||||||
"texts": ""
|
"texts": "Teksten"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Canvas opnieuw instellen",
|
"clearReset": "Canvas opnieuw instellen",
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
"exportImage": "Exporteer afbeelding...",
|
"exportImage": "Exporteer afbeelding...",
|
||||||
"export": "Sla op...",
|
"export": "Sla op...",
|
||||||
"copyToClipboard": "Kopieer",
|
"copyToClipboard": "Kopieer",
|
||||||
"copyLink": "",
|
"copyLink": "Link kopiëren",
|
||||||
"save": "Opslaan naar huidige bestand",
|
"save": "Opslaan naar huidige bestand",
|
||||||
"saveAs": "Opslaan als",
|
"saveAs": "Opslaan als",
|
||||||
"load": "Open",
|
"load": "Open",
|
||||||
@@ -225,16 +225,16 @@
|
|||||||
"fullScreen": "Volledig scherm",
|
"fullScreen": "Volledig scherm",
|
||||||
"darkMode": "Donkere modus",
|
"darkMode": "Donkere modus",
|
||||||
"lightMode": "Lichte modus",
|
"lightMode": "Lichte modus",
|
||||||
"systemMode": "",
|
"systemMode": "Systeemmodus",
|
||||||
"zenMode": "Zen modus",
|
"zenMode": "Zen modus",
|
||||||
"objectsSnapMode": "",
|
"objectsSnapMode": "Uitlijnen op objecten",
|
||||||
"exitZenMode": "Verlaat zen modus",
|
"exitZenMode": "Verlaat zen modus",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"saveLibNames": "",
|
"saveLibNames": "Namen opslaan en afsluiten",
|
||||||
"clear": "Wissen",
|
"clear": "Wissen",
|
||||||
"remove": "Verwijderen",
|
"remove": "Verwijderen",
|
||||||
"embed": "Insluiten in-/uitschakelen",
|
"embed": "Embedden in-/uitschakelen",
|
||||||
"publishLibrary": "",
|
"publishLibrary": "Hernoemen of publiceren",
|
||||||
"submit": "Versturen",
|
"submit": "Versturen",
|
||||||
"confirm": "Bevestigen",
|
"confirm": "Bevestigen",
|
||||||
"embeddableInteractionButton": "Klik voor interactie"
|
"embeddableInteractionButton": "Klik voor interactie"
|
||||||
@@ -261,38 +261,38 @@
|
|||||||
"removeItemsFromsLibrary": "Verwijder {{count}} item(s) uit bibliotheek?",
|
"removeItemsFromsLibrary": "Verwijder {{count}} item(s) uit bibliotheek?",
|
||||||
"invalidEncryptionKey": "Encryptiesleutel moet 22 tekens zijn. Live samenwerking is uitgeschakeld.",
|
"invalidEncryptionKey": "Encryptiesleutel moet 22 tekens zijn. Live samenwerking is uitgeschakeld.",
|
||||||
"collabOfflineWarning": "Geen internetverbinding beschikbaar.\nJe wijzigingen worden niet opgeslagen!",
|
"collabOfflineWarning": "Geen internetverbinding beschikbaar.\nJe wijzigingen worden niet opgeslagen!",
|
||||||
"localStorageQuotaExceeded": ""
|
"localStorageQuotaExceeded": "Limiet voor browser opslag overschreden. Wijzigingen worden niet opgeslagen."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"unsupportedFileType": "Niet-ondersteund bestandstype.",
|
"unsupportedFileType": "Niet-ondersteund bestandstype.",
|
||||||
"imageInsertError": "Afbeelding invoegen mislukt. Probeer het later opnieuw...",
|
"imageInsertError": "Afbeelding invoegen mislukt. Probeer het later opnieuw...",
|
||||||
"fileTooBig": "Bestand is te groot. Maximale grootte is {{maxSize}}.",
|
"fileTooBig": "Bestand is te groot. Maximale grootte is {{maxSize}}.",
|
||||||
"svgImageInsertError": "Kon geen SVG-afbeelding invoegen. De SVG-opmaak ziet er niet geldig uit.",
|
"svgImageInsertError": "Kon geen SVG-afbeelding invoegen. De SVG-opmaak ziet er niet geldig uit.",
|
||||||
"failedToFetchImage": "",
|
"failedToFetchImage": "Afbeeldingen ophalen mislukt.",
|
||||||
"cannotResolveCollabServer": "Kan geen verbinding maken met de collab server. Herlaad de pagina en probeer het opnieuw.",
|
"cannotResolveCollabServer": "Kan geen verbinding maken met de collab server. Herlaad de pagina en probeer het opnieuw.",
|
||||||
"importLibraryError": "Kon bibliotheek niet laden",
|
"importLibraryError": "Kon bibliotheek niet laden",
|
||||||
"saveLibraryError": "",
|
"saveLibraryError": "Kan bibliotheek niet opslaan in opslag. Sla uw bibliotheek op lokaal bestand om ervoor te zorgen dat u geen wijzigingen verliest.",
|
||||||
"collabSaveFailed": "Kan niet opslaan in de backend database. Als de problemen blijven bestaan, moet u het bestand lokaal opslaan om ervoor te zorgen dat u uw werk niet verliest.",
|
"collabSaveFailed": "Kan niet opslaan in de backend database. Als de problemen blijven bestaan, moet u het bestand lokaal opslaan om ervoor te zorgen dat u uw werk niet verliest.",
|
||||||
"collabSaveFailed_sizeExceeded": "Kan de backend database niet opslaan, het canvas lijkt te groot te zijn. U moet het bestand lokaal opslaan om ervoor te zorgen dat u uw werk niet verliest.",
|
"collabSaveFailed_sizeExceeded": "Kan de backend database niet opslaan, het canvas lijkt te groot te zijn. U moet het bestand lokaal opslaan om ervoor te zorgen dat u uw werk niet verliest.",
|
||||||
"imageToolNotSupported": "",
|
"imageToolNotSupported": "Afbeeldingen zijn uitgeschakeld.",
|
||||||
"brave_measure_text_error": {
|
"brave_measure_text_error": {
|
||||||
"line1": "",
|
"line1": "Het lijkt erop dat je de Brave browser gebruikt met de instelling <bold>Agressief vingerafdrukken blokkeren</bold> ingeschakeld.",
|
||||||
"line2": "",
|
"line2": "Dit kan leiden tot het breken van de <bold>Tekst Elementen</bold> in je tekening.",
|
||||||
"line3": "",
|
"line3": "We raden sterk aan om deze instelling uit te zetten. Je kan <link>deze stappen</link> volgen om te zien hoe.",
|
||||||
"line4": ""
|
"line4": "Als het uitschakelen van deze instelling het weergeven van tekstelementen niet oplost, open dan een <issueLink>issue</issueLink> op onze GitHub, of stuur ons een bericht op <discordLink>Discord</discordLink>"
|
||||||
},
|
},
|
||||||
"libraryElementTypeError": {
|
"libraryElementTypeError": {
|
||||||
"embeddable": "Ingesloten elementen kunnen niet worden toegevoegd aan de bibliotheek.",
|
"embeddable": "Ingesloten elementen kunnen niet worden toegevoegd aan de bibliotheek.",
|
||||||
"iframe": "",
|
"iframe": "IFrame elementen kunnen niet worden toegevoegd aan de bibliotheek.",
|
||||||
"image": "Ondersteuning voor het toevoegen van afbeeldingen aan de bibliotheek komt binnenkort!"
|
"image": "Ondersteuning voor het toevoegen van afbeeldingen aan de bibliotheek komt binnenkort!"
|
||||||
},
|
},
|
||||||
"asyncPasteFailedOnRead": "",
|
"asyncPasteFailedOnRead": "Plakken mislukt (kan niet van het systeem klembord lezen).",
|
||||||
"asyncPasteFailedOnParse": "Kon niet plakken.",
|
"asyncPasteFailedOnParse": "Kon niet plakken.",
|
||||||
"copyToSystemClipboardFailed": "Kon niet naar klembord kopiëren."
|
"copyToSystemClipboardFailed": "Kon niet naar klembord kopiëren."
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Selectie",
|
"selection": "Selectie",
|
||||||
"lasso": "",
|
"lasso": "Lasso selectie",
|
||||||
"image": "Voeg afbeelding in",
|
"image": "Voeg afbeelding in",
|
||||||
"rectangle": "Rechthoek",
|
"rectangle": "Rechthoek",
|
||||||
"diamond": "Ruit",
|
"diamond": "Ruit",
|
||||||
@@ -304,32 +304,32 @@
|
|||||||
"library": "Bibliotheek",
|
"library": "Bibliotheek",
|
||||||
"lock": "Geselecteerde tool actief houden na tekenen",
|
"lock": "Geselecteerde tool actief houden na tekenen",
|
||||||
"penMode": "Pen modus - Blokkeer aanraken",
|
"penMode": "Pen modus - Blokkeer aanraken",
|
||||||
"link": "",
|
"link": "Link toevoegen / bijwerken voor een geselecteerde vorm",
|
||||||
"eraser": "Gum",
|
"eraser": "Gum",
|
||||||
"frame": "Frame tool",
|
"frame": "Frame tool",
|
||||||
"magicframe": "",
|
"magicframe": "Wireframe naar code",
|
||||||
"embeddable": "Web insluiten",
|
"embeddable": "Web Embed",
|
||||||
"laser": "Laseraanwijzer",
|
"laser": "Laseraanwijzer",
|
||||||
"hand": "",
|
"hand": "Hand (panning tool)",
|
||||||
"extraTools": "Meer tools",
|
"extraTools": "Meer tools",
|
||||||
"mermaidToExcalidraw": "",
|
"mermaidToExcalidraw": "Mermaid naar Excalidraw",
|
||||||
"convertElementType": ""
|
"convertElementType": "Vorm type in-/uitschakelen"
|
||||||
},
|
},
|
||||||
"element": {
|
"element": {
|
||||||
"rectangle": "",
|
"rectangle": "Rechthoek",
|
||||||
"diamond": "",
|
"diamond": "Diamant",
|
||||||
"ellipse": "",
|
"ellipse": "Ovaal",
|
||||||
"arrow": "",
|
"arrow": "Pijl",
|
||||||
"line": "",
|
"line": "Lijn",
|
||||||
"freedraw": "",
|
"freedraw": "Los tekenen",
|
||||||
"text": "",
|
"text": "Tekst",
|
||||||
"image": "",
|
"image": "Afbeelding",
|
||||||
"group": "",
|
"group": "Groep",
|
||||||
"frame": "",
|
"frame": "Kader",
|
||||||
"magicframe": "",
|
"magicframe": "Wireframe naar code",
|
||||||
"embeddable": "",
|
"embeddable": "Web Embed",
|
||||||
"selection": "",
|
"selection": "Selectie",
|
||||||
"iframe": ""
|
"iframe": "IFrame"
|
||||||
},
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"canvasActions": "Canvasacties",
|
"canvasActions": "Canvasacties",
|
||||||
@@ -337,34 +337,34 @@
|
|||||||
"shapes": "Vormen"
|
"shapes": "Vormen"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"dismissSearch": "",
|
"dismissSearch": "{{shortcut}} om zoekopdracht te sluiten",
|
||||||
"canvasPanning": "",
|
"canvasPanning": "Om canvas te verplaatsen, houd {{shortcut_1}} of {{shortcut_2}} ingedrukt tijdens slepen, of gebruik de hand tool",
|
||||||
"linearElement": "Klik om meerdere punten te starten, sleep voor één lijn",
|
"linearElement": "Klik om meerdere punten te starten, sleep voor één lijn",
|
||||||
"arrowTool": "",
|
"arrowTool": "Klik om meerdere punten te starten, sleep naar een enkele lijn. Druk nogmaals op {{shortcut}} om het pijltype te wijzigen.",
|
||||||
"arrowBindModifiers": "",
|
"arrowBindModifiers": "Houd {{shortcut_1}} ingedrukt om binding uit te schakelen of {{shortcut_2}} om op een vast punt te binden",
|
||||||
"freeDraw": "Klik en sleep, laat los als je klaar bent",
|
"freeDraw": "Klik en sleep, laat los als je klaar bent",
|
||||||
"text": "Tip: je kunt tekst toevoegen door ergens dubbel te klikken met de selectietool",
|
"text": "Tip: je kunt tekst toevoegen door ergens dubbel te klikken met de selectietool",
|
||||||
"embeddable": "Klink-sleep om een website-insluiting te maken",
|
"embeddable": "Klink-sleep om een website-insluiting te maken",
|
||||||
"text_selected": "",
|
"text_selected": "Dubbelklik of druk op {{shortcut}} om de tekst te bewerken",
|
||||||
"text_editing": "",
|
"text_editing": "Druk op {{shortcut_1}} of {{shortcut_2}} om het bewerken te voltooien",
|
||||||
"linearElementMulti": "",
|
"linearElementMulti": "Klik op het laatste punt of druk op {{shortcut_1}} of {{shortcut_2}} om te voltooien",
|
||||||
"lockAngle": "",
|
"lockAngle": "Je kan hoek beperken door {{shortcut}} ingedrukt te houden",
|
||||||
"resize": "",
|
"resize": "Je kunt de verhoudingen behouden door {{shortcut_1}} ingedrukt te houden tijdens het schalen,\nhoud {{shortcut_2}} ingedrukt om vanuit het midden te schalen",
|
||||||
"resizeImage": "",
|
"resizeImage": "Je kunt vrij vergroten of verkleinen door {{shortcut_1}} ingedrukt te houden,\nhoud {{shortcut_2}} ingedrukt om vanuit het midden te schalen",
|
||||||
"rotate": "",
|
"rotate": "Je kan hoeken beperken door {{shortcut}} ingedrukt te houden tijdens het draaien",
|
||||||
"lineEditor_info": "",
|
"lineEditor_info": "Houd {{shortcut_1}} en Dubbelklik of druk op {{shortcut_2}} om punten te bewerken",
|
||||||
"lineEditor_line_info": "",
|
"lineEditor_line_info": "Dubbelklik of druk op {{shortcut}} om punten te bewerken",
|
||||||
"lineEditor_pointSelected": "",
|
"lineEditor_pointSelected": "Druk op {{shortcut_1}} om punt(en) te verwijderen,\n{{shortcut_2}} om te dupliceren, of sleep om te verplaatsen",
|
||||||
"lineEditor_nothingSelected": "",
|
"lineEditor_nothingSelected": "Selecteer een punt om te bewerken (houd {{shortcut_1}} ingedrukt om meerdere te selecteren),\nof houd {{shortcut_2}} ingedrukt en klik om nieuwe punten toe te voegen",
|
||||||
"publishLibrary": "Publiceer je eigen bibliotheek",
|
"publishLibrary": "Publiceer je eigen bibliotheek",
|
||||||
"bindTextToElement": "",
|
"bindTextToElement": "{{shortcut}} om tekst toe te voegen",
|
||||||
"createFlowchart": "",
|
"createFlowchart": "{{shortcut}} voor het maken van een flowchart",
|
||||||
"deepBoxSelect": "",
|
"deepBoxSelect": "Houd {{shortcut}} ingedrukt om diep te selecteren, en om slepen te voorkomen",
|
||||||
"eraserRevert": "",
|
"eraserRevert": "Houd {{shortcut}} ingedrukt om de elementen die voor verwijdering zijn gemarkeerd te herstellen",
|
||||||
"firefox_clipboard_write": "",
|
"firefox_clipboard_write": "Deze functie kan waarschijnlijk worden ingeschakeld door de vlag \"dom.events.asyncClipboard.clipboardItem\" op \"true\" te zetten. Om de browser flags in Firefox te wijzigen, ga naar de pagina \"about:config\".",
|
||||||
"disableSnapping": "",
|
"disableSnapping": "Houd {{shortcut}} ingedrukt om uitlijnen uit te schakelen",
|
||||||
"enterCropEditor": "",
|
"enterCropEditor": "Dubbelklik op de afbeelding of druk op {{shortcut}} om de afbeelding bij te snijden",
|
||||||
"leaveCropEditor": ""
|
"leaveCropEditor": "Klik buiten de afbeelding of druk op {{shortcut_1}} of {{shortcut_2}} om het bijsnijden te voltooien"
|
||||||
},
|
},
|
||||||
"canvasError": {
|
"canvasError": {
|
||||||
"cannotShowPreview": "Kan voorbeeld niet tonen",
|
"cannotShowPreview": "Kan voorbeeld niet tonen",
|
||||||
@@ -380,11 +380,11 @@
|
|||||||
"sceneContent": "Scène-inhoud:"
|
"sceneContent": "Scène-inhoud:"
|
||||||
},
|
},
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"or": ""
|
"or": "Of"
|
||||||
},
|
},
|
||||||
"roomDialog": {
|
"roomDialog": {
|
||||||
"desc_intro": "",
|
"desc_intro": "Nodig mensen uit om samen te werken aan je tekening.",
|
||||||
"desc_privacy": "",
|
"desc_privacy": "Geen zorgen, de sessie is end-to-end versleuteld en volledig privé. Niet eens onze server kan zien wat je tekent.",
|
||||||
"button_startSession": "Start sessie",
|
"button_startSession": "Start sessie",
|
||||||
"button_stopSession": "Stop sessie",
|
"button_stopSession": "Stop sessie",
|
||||||
"desc_inProgressIntro": "De live-samenwerkingssessie is nu gestart.",
|
"desc_inProgressIntro": "De live-samenwerkingssessie is nu gestart.",
|
||||||
@@ -410,34 +410,34 @@
|
|||||||
"blog": "Lees onze blog",
|
"blog": "Lees onze blog",
|
||||||
"click": "klik",
|
"click": "klik",
|
||||||
"deepSelect": "Deep selecteer",
|
"deepSelect": "Deep selecteer",
|
||||||
"deepBoxSelect": "",
|
"deepBoxSelect": "Diep selecteren binnen het kader en slepen voorkomen",
|
||||||
"createFlowchart": "",
|
"createFlowchart": "Maak een flowchart van een generiek element",
|
||||||
"navigateFlowchart": "",
|
"navigateFlowchart": "Navigeer naar een flowchart",
|
||||||
"curvedArrow": "Gebogen pijl",
|
"curvedArrow": "Gebogen pijl",
|
||||||
"curvedLine": "Kromme lijn",
|
"curvedLine": "Kromme lijn",
|
||||||
"documentation": "Documentatie",
|
"documentation": "Documentatie",
|
||||||
"doubleClick": "dubbelklikken",
|
"doubleClick": "dubbelklikken",
|
||||||
"drag": "slepen",
|
"drag": "slepen",
|
||||||
"editor": "",
|
"editor": "Editor",
|
||||||
"editLineArrowPoints": "",
|
"editLineArrowPoints": "Lijn/pijl punten bewerken",
|
||||||
"editText": "",
|
"editText": "Tekst bewerken / label toevoegen",
|
||||||
"github": "Probleem gevonden? Verzenden",
|
"github": "Probleem gevonden? Verzenden",
|
||||||
"howto": "Volg onze handleidingen",
|
"howto": "Volg onze handleidingen",
|
||||||
"or": "of",
|
"or": "of",
|
||||||
"preventBinding": "Pijlbinding voorkomen",
|
"preventBinding": "Pijlbinding voorkomen",
|
||||||
"tools": "",
|
"tools": "Tools",
|
||||||
"shortcuts": "Sneltoetsen",
|
"shortcuts": "Sneltoetsen",
|
||||||
"textFinish": "Voltooi het bewerken (teksteditor)",
|
"textFinish": "Voltooi het bewerken (teksteditor)",
|
||||||
"textNewLine": "Nieuwe regel toevoegen (teksteditor)",
|
"textNewLine": "Nieuwe regel toevoegen (teksteditor)",
|
||||||
"title": "",
|
"title": "Help",
|
||||||
"view": "Weergave",
|
"view": "Weergave",
|
||||||
"zoomToFit": "Zoom in op alle elementen",
|
"zoomToFit": "Zoom in op alle elementen",
|
||||||
"zoomToSelection": "Inzoomen op selectie",
|
"zoomToSelection": "Inzoomen op selectie",
|
||||||
"toggleElementLock": "",
|
"toggleElementLock": "Selectie vergrendelen/ontgrendelen",
|
||||||
"movePageUpDown": "Pagina omhoog/omlaag",
|
"movePageUpDown": "Pagina omhoog/omlaag",
|
||||||
"movePageLeftRight": "Verplaats pagina links/rechts",
|
"movePageLeftRight": "Verplaats pagina links/rechts",
|
||||||
"cropStart": "",
|
"cropStart": "Afbeelding bijsnijden",
|
||||||
"cropFinish": ""
|
"cropFinish": "Afbeelding bijsnijden voltooien"
|
||||||
},
|
},
|
||||||
"clearCanvasDialog": {
|
"clearCanvasDialog": {
|
||||||
"title": "Wis canvas"
|
"title": "Wis canvas"
|
||||||
@@ -450,13 +450,13 @@
|
|||||||
"twitterUsername": "Twitter gebruikersnaam",
|
"twitterUsername": "Twitter gebruikersnaam",
|
||||||
"libraryName": "Naam bibliotheek",
|
"libraryName": "Naam bibliotheek",
|
||||||
"libraryDesc": "Beschrijving van de bibliotheek",
|
"libraryDesc": "Beschrijving van de bibliotheek",
|
||||||
"website": "",
|
"website": "Website",
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"authorName": "Je naam of gebruikersnaam",
|
"authorName": "Je naam of gebruikersnaam",
|
||||||
"libraryName": "Naam van je bibliotheek",
|
"libraryName": "Naam van je bibliotheek",
|
||||||
"libraryDesc": "Beschrijving van je bibliotheek om mensen te helpen het gebruik ervan te begrijpen",
|
"libraryDesc": "Beschrijving van je bibliotheek om mensen te helpen het gebruik ervan te begrijpen",
|
||||||
"githubHandle": "",
|
"githubHandle": "GitHub-gebruikersnaam (optioneel), zodat je de bibliotheek kunt bewerken zodra deze ter beoordeling is ingediend",
|
||||||
"twitterHandle": "",
|
"twitterHandle": "Twitter-gebruikersnaam (optioneel), zodat we weten wie we moeten vermelden bij promotie op Twitter",
|
||||||
"website": "Link naar je persoonlijke website of elders (optioneel)"
|
"website": "Link naar je persoonlijke website of elders (optioneel)"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -466,9 +466,9 @@
|
|||||||
"noteDescription": "<link>openbare repository</link>",
|
"noteDescription": "<link>openbare repository</link>",
|
||||||
"noteGuidelines": "<link>richtlijnen</link>",
|
"noteGuidelines": "<link>richtlijnen</link>",
|
||||||
"noteLicense": "<link>MIT-licentie, </link>",
|
"noteLicense": "<link>MIT-licentie, </link>",
|
||||||
"noteItems": "",
|
"noteItems": "Elk bibliotheekitem moet een eigen naam hebben zodat het gefilterd kan worden. De volgende bibliotheekitems worden geïncludeerd:",
|
||||||
"atleastOneLibItem": "",
|
"atleastOneLibItem": "Selecteer op zijn minst één bibliotheekitem om te starten",
|
||||||
"republishWarning": ""
|
"republishWarning": "Opmerking: sommige van de geselecteerde items zijn al gepubliceerd/ingediend. Je moet items alleen opnieuw indienen bij het bijwerken van een bestaande bibliotheek of inzending."
|
||||||
},
|
},
|
||||||
"publishSuccessDialog": {
|
"publishSuccessDialog": {
|
||||||
"title": "Bibliotheek ingediend",
|
"title": "Bibliotheek ingediend",
|
||||||
@@ -479,17 +479,17 @@
|
|||||||
"removeItemsFromLib": "Verwijder geselecteerde items uit bibliotheek"
|
"removeItemsFromLib": "Verwijder geselecteerde items uit bibliotheek"
|
||||||
},
|
},
|
||||||
"imageExportDialog": {
|
"imageExportDialog": {
|
||||||
"header": "",
|
"header": "Exporteer afbeelding",
|
||||||
"label": {
|
"label": {
|
||||||
"withBackground": "Achtergrond",
|
"withBackground": "Achtergrond",
|
||||||
"onlySelected": "",
|
"onlySelected": "Alleen geselecteerd",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
"embedScene": "",
|
"embedScene": "Scène embedden",
|
||||||
"scale": "Schaal",
|
"scale": "Schaal",
|
||||||
"padding": "Padding"
|
"padding": "Padding"
|
||||||
},
|
},
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"embedScene": ""
|
"embedScene": "Scènegegevens worden opgeslagen in het geëxporteerde PNG/SVG-bestand, zodat de scène hieruit kan worden hersteld.\nDit zal de geëxporteerde bestandsgrootte verhogen."
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"exportToPng": "Exporteer naar PNG",
|
"exportToPng": "Exporteer naar PNG",
|
||||||
@@ -499,7 +499,7 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"exportToPng": "PNG",
|
"exportToPng": "PNG",
|
||||||
"exportToSvg": "SVG",
|
"exportToSvg": "SVG",
|
||||||
"copyPngToClipboard": ""
|
"copyPngToClipboard": "Kopieer naar klembord"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"encrypted": {
|
"encrypted": {
|
||||||
@@ -508,15 +508,15 @@
|
|||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"angle": "Hoek",
|
"angle": "Hoek",
|
||||||
"shapes": "",
|
"shapes": "Vormen",
|
||||||
"height": "Hoogte",
|
"height": "Hoogte",
|
||||||
"scene": "Scene",
|
"scene": "Scene",
|
||||||
"selected": "Geselecteerd",
|
"selected": "Geselecteerd",
|
||||||
"storage": "Opslag",
|
"storage": "Opslag",
|
||||||
"fullTitle": "",
|
"fullTitle": "Canvas & Vorm eigenschappen",
|
||||||
"title": "",
|
"title": "Eigenschappen",
|
||||||
"generalStats": "",
|
"generalStats": "Algemeen",
|
||||||
"elementProperties": "",
|
"elementProperties": "Vorm eigenschappen",
|
||||||
"total": "Totaal",
|
"total": "Totaal",
|
||||||
"version": "Versie",
|
"version": "Versie",
|
||||||
"versionCopy": "Klik om te kopiëren",
|
"versionCopy": "Klik om te kopiëren",
|
||||||
@@ -528,15 +528,15 @@
|
|||||||
"copyStyles": "Stijlen gekopieerd.",
|
"copyStyles": "Stijlen gekopieerd.",
|
||||||
"copyToClipboard": "Gekopieerd naar het klembord.",
|
"copyToClipboard": "Gekopieerd naar het klembord.",
|
||||||
"copyToClipboardAsPng": "{{exportSelection}} naar klembord gekopieerd als PNG\n({{exportColorScheme}})",
|
"copyToClipboardAsPng": "{{exportSelection}} naar klembord gekopieerd als PNG\n({{exportColorScheme}})",
|
||||||
"copyToClipboardAsSvg": "",
|
"copyToClipboardAsSvg": "{{exportSelection}} gekopieerd naar klembord als SVG\n({{exportColorScheme}})",
|
||||||
"fileSaved": "Bestand opgeslagen.",
|
"fileSaved": "Bestand opgeslagen.",
|
||||||
"fileSavedToFilename": "Opgeslagen als {filename}",
|
"fileSavedToFilename": "Opgeslagen als {filename}",
|
||||||
"canvas": "canvas",
|
"canvas": "canvas",
|
||||||
"selection": "selectie",
|
"selection": "selectie",
|
||||||
"pasteAsSingleElement": "Gebruik {{shortcut}} om te plakken als een enkel element,\nof plak in een bestaande teksteditor",
|
"pasteAsSingleElement": "Gebruik {{shortcut}} om te plakken als een enkel element,\nof plak in een bestaande teksteditor",
|
||||||
"unableToEmbed": "Het insluiten van deze url is momenteel niet toegestaan. Zet een probleem op GitHub om de URL op de whitelist te zetten",
|
"unableToEmbed": "Het insluiten van deze URL is momenteel niet toegestaan. Zet een probleem op GitHub om de URL op de whitelist te zetten",
|
||||||
"unrecognizedLinkFormat": "De link die u hebt ingesloten komt niet overeen met het verwachte formaat. Probeer de 'embed' string van de bronsite te plakken",
|
"unrecognizedLinkFormat": "De link die u hebt ingesloten komt niet overeen met het verwachte formaat. Probeer de 'embed' string van de bronsite te plakken",
|
||||||
"elementLinkCopied": ""
|
"elementLinkCopied": "Link gekopieerd naar klembord"
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"transparent": "Transparant",
|
"transparent": "Transparant",
|
||||||
@@ -544,22 +544,22 @@
|
|||||||
"white": "Wit",
|
"white": "Wit",
|
||||||
"red": "Rood",
|
"red": "Rood",
|
||||||
"pink": "Roze",
|
"pink": "Roze",
|
||||||
"grape": "",
|
"grape": "Druif",
|
||||||
"violet": "",
|
"violet": "Violet",
|
||||||
"gray": "",
|
"gray": "Grijs",
|
||||||
"blue": "",
|
"blue": "Blauw",
|
||||||
"cyan": "",
|
"cyan": "Cyaan",
|
||||||
"teal": "",
|
"teal": "Groenblauw",
|
||||||
"green": "",
|
"green": "Groen",
|
||||||
"yellow": "",
|
"yellow": "Geel",
|
||||||
"orange": "",
|
"orange": "Oranje",
|
||||||
"bronze": ""
|
"bronze": "Brons"
|
||||||
},
|
},
|
||||||
"welcomeScreen": {
|
"welcomeScreen": {
|
||||||
"app": {
|
"app": {
|
||||||
"center_heading": "",
|
"center_heading": "Al je data is lokaal opgeslagen in je browser.",
|
||||||
"center_heading_plus": "",
|
"center_heading_plus": "Wil je in plaats daarvan naar Excalidraw+ gaan?",
|
||||||
"menuHint": ""
|
"menuHint": "Exporteren, voorkeuren en meer, ..."
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"menuHint": "Exporteren, voorkeuren en meer...",
|
"menuHint": "Exporteren, voorkeuren en meer...",
|
||||||
@@ -569,97 +569,97 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
"color": "",
|
"color": "Kleur",
|
||||||
"mostUsedCustomColors": "",
|
"mostUsedCustomColors": "Meest gebruikte aangepaste kleur",
|
||||||
"colors": "",
|
"colors": "Kleuren",
|
||||||
"shades": "",
|
"shades": "Tinten",
|
||||||
"hexCode": "",
|
"hexCode": "HEX-code",
|
||||||
"noShades": ""
|
"noShades": "Geen tinten beschikbaar voor deze kleur"
|
||||||
},
|
},
|
||||||
"overwriteConfirm": {
|
"overwriteConfirm": {
|
||||||
"action": {
|
"action": {
|
||||||
"exportToImage": {
|
"exportToImage": {
|
||||||
"title": "",
|
"title": "Exporteer als afbeelding",
|
||||||
"button": "",
|
"button": "Exporteer als afbeelding",
|
||||||
"description": ""
|
"description": "Exporteer de scènegegevens als een afbeelding die je later weer kunt importeren."
|
||||||
},
|
},
|
||||||
"saveToDisk": {
|
"saveToDisk": {
|
||||||
"title": "",
|
"title": "Opslaan op schijf",
|
||||||
"button": "",
|
"button": "Opslaan op schijf",
|
||||||
"description": ""
|
"description": "Exporteer de scènegegevens als bestand die je later weer kunt importeren."
|
||||||
},
|
},
|
||||||
"excalidrawPlus": {
|
"excalidrawPlus": {
|
||||||
"title": "",
|
"title": "Excalidraw+",
|
||||||
"button": "",
|
"button": "Exporteer naar Excalidraw+",
|
||||||
"description": ""
|
"description": "Sla de scène op in je Excalidraw+ werkruimte."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"loadFromFile": {
|
"loadFromFile": {
|
||||||
"title": "",
|
"title": "Laad vanuit bestand",
|
||||||
"button": "",
|
"button": "Laad vanuit bestand",
|
||||||
"description": ""
|
"description": "Het laden van een bestand zal <bold>je bestaande inhoud vervangen</bold>.<br></br>Je kunt eerst een back-up van je tekening maken via een van de onderstaande opties."
|
||||||
},
|
},
|
||||||
"shareableLink": {
|
"shareableLink": {
|
||||||
"title": "",
|
"title": "Laad vanuit link",
|
||||||
"button": "",
|
"button": "Vervang mijn inhoud",
|
||||||
"description": ""
|
"description": "Het laden van externe tekening zal <bold>je bestaande inhoud vervangen</bold>.<br></br>Je kunt eerst een back-up van je tekening maken via een van de onderstaande opties."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mermaid": {
|
"mermaid": {
|
||||||
"title": "",
|
"title": "Mermaid naar Excalidraw",
|
||||||
"button": "",
|
"button": "Invoegen",
|
||||||
"description": "",
|
"description": "Momenteel worden alleen <flowchartLink>Flowchart</flowchartLink>-, <sequenceLink>Sequence</sequenceLink>- en <classLink>Class</classLink>-diagrammen ondersteund. De andere types worden als afbeelding weergegeven in Excalidraw.",
|
||||||
"syntax": "",
|
"syntax": "Mermaid Syntaxis",
|
||||||
"preview": ""
|
"preview": "Voorbeeld"
|
||||||
},
|
},
|
||||||
"quickSearch": {
|
"quickSearch": {
|
||||||
"placeholder": ""
|
"placeholder": "Snel Zoeken"
|
||||||
},
|
},
|
||||||
"fontList": {
|
"fontList": {
|
||||||
"badge": {
|
"badge": {
|
||||||
"old": ""
|
"old": "oud"
|
||||||
},
|
},
|
||||||
"sceneFonts": "",
|
"sceneFonts": "In deze scène",
|
||||||
"availableFonts": "",
|
"availableFonts": "Beschikbare lettertypen",
|
||||||
"empty": ""
|
"empty": "Geen lettertypes gevonden"
|
||||||
},
|
},
|
||||||
"userList": {
|
"userList": {
|
||||||
"empty": "",
|
"empty": "Geen gebruikers gevonden",
|
||||||
"hint": {
|
"hint": {
|
||||||
"text": "",
|
"text": "Klik op de gebruiker om te volgen",
|
||||||
"followStatus": "",
|
"followStatus": "Je volgt momenteel deze gebruiker",
|
||||||
"inCall": "",
|
"inCall": "Gebruiker is in een spraakoproep",
|
||||||
"micMuted": "",
|
"micMuted": "Microfoon van gebruiker is gedempt",
|
||||||
"isSpeaking": ""
|
"isSpeaking": "Gebruiker praat"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"commandPalette": {
|
"commandPalette": {
|
||||||
"title": "",
|
"title": "Opdrachtenpalet",
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"select": "",
|
"select": "Selecteer",
|
||||||
"confirm": "",
|
"confirm": "Bevestigen",
|
||||||
"close": ""
|
"close": "Sluiten"
|
||||||
},
|
},
|
||||||
"recents": "",
|
"recents": "Laatst gebruikt",
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "",
|
"placeholder": "Zoek in menu's, commando's en ontdek geheimpjes",
|
||||||
"noMatch": ""
|
"noMatch": "Geen overeenkomende opdrachten..."
|
||||||
},
|
},
|
||||||
"itemNotAvailable": "",
|
"itemNotAvailable": "Opdracht is niet beschikbaar...",
|
||||||
"shortcutHint": ""
|
"shortcutHint": "Gebruik {{shortcut}} voor het palet"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"ctrl": "",
|
"ctrl": "Ctrl",
|
||||||
"option": "",
|
"option": "Optie",
|
||||||
"cmd": "",
|
"cmd": "Cmd",
|
||||||
"alt": "",
|
"alt": "Alt",
|
||||||
"escape": "",
|
"escape": "Esc",
|
||||||
"enter": "",
|
"enter": "Enter",
|
||||||
"shift": "",
|
"shift": "Shift",
|
||||||
"spacebar": "",
|
"spacebar": "Spatie",
|
||||||
"delete": "",
|
"delete": "Delete",
|
||||||
"mmb": ""
|
"mmb": "Muiswiel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"cs-CZ": 82,
|
"cs-CZ": 82,
|
||||||
"da-DK": 30,
|
"da-DK": 30,
|
||||||
"de-CH": 91,
|
"de-CH": 91,
|
||||||
"de-DE": 91,
|
"de-DE": 92,
|
||||||
"el-GR": 86,
|
"el-GR": 86,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-ES": 91,
|
"es-ES": 91,
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"hi-IN": 91,
|
"hi-IN": 91,
|
||||||
"hu-HU": 53,
|
"hu-HU": 53,
|
||||||
"id-ID": 90,
|
"id-ID": 90,
|
||||||
"it-IT": 99,
|
"it-IT": 100,
|
||||||
"ja-JP": 87,
|
"ja-JP": 87,
|
||||||
"kaa": 23,
|
"kaa": 23,
|
||||||
"kab-KAB": 60,
|
"kab-KAB": 60,
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"mr-IN": 91,
|
"mr-IN": 91,
|
||||||
"my-MM": 27,
|
"my-MM": 27,
|
||||||
"nb-NO": 91,
|
"nb-NO": 91,
|
||||||
"nl-NL": 58,
|
"nl-NL": 100,
|
||||||
"nn-NO": 48,
|
"nn-NO": 48,
|
||||||
"oc-FR": 84,
|
"oc-FR": 84,
|
||||||
"pa-IN": 58,
|
"pa-IN": 58,
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"ro-RO": 100,
|
"ro-RO": 100,
|
||||||
"ru-RU": 99,
|
"ru-RU": 99,
|
||||||
"si-LK": 75,
|
"si-LK": 75,
|
||||||
"sk-SK": 91,
|
"sk-SK": 100,
|
||||||
"sl-SI": 91,
|
"sl-SI": 91,
|
||||||
"sv-SE": 91,
|
"sv-SE": 91,
|
||||||
"ta-IN": 86,
|
"ta-IN": 86,
|
||||||
|
|||||||
@@ -142,8 +142,8 @@
|
|||||||
"editArrow": "Upraviť šípku"
|
"editArrow": "Upraviť šípku"
|
||||||
},
|
},
|
||||||
"polygon": {
|
"polygon": {
|
||||||
"breakPolygon": "",
|
"breakPolygon": "Rozdeliť polygón",
|
||||||
"convertToPolygon": ""
|
"convertToPolygon": "Zmeniť na polygón"
|
||||||
},
|
},
|
||||||
"elementLock": {
|
"elementLock": {
|
||||||
"lock": "Zamknúť",
|
"lock": "Zamknúť",
|
||||||
@@ -170,8 +170,8 @@
|
|||||||
"copyElementLink": "Kopírovať odkaz na objekt",
|
"copyElementLink": "Kopírovať odkaz na objekt",
|
||||||
"linkToElement": "Odkaz na objekt",
|
"linkToElement": "Odkaz na objekt",
|
||||||
"wrapSelectionInFrame": "Zabaliť výber do rámu",
|
"wrapSelectionInFrame": "Zabaliť výber do rámu",
|
||||||
"tab": "",
|
"tab": "Tab",
|
||||||
"shapeSwitch": ""
|
"shapeSwitch": "Zmeniť tvar"
|
||||||
},
|
},
|
||||||
"elementLink": {
|
"elementLink": {
|
||||||
"title": "Odkaz na objekt",
|
"title": "Odkaz na objekt",
|
||||||
@@ -183,10 +183,10 @@
|
|||||||
"hint_emptyLibrary": "Vyberte položku z plátna pre jej pridanie do knižnice alebo použite knižnicu z verejného zoznamu knižníc nižšie.",
|
"hint_emptyLibrary": "Vyberte položku z plátna pre jej pridanie do knižnice alebo použite knižnicu z verejného zoznamu knižníc nižšie.",
|
||||||
"hint_emptyPrivateLibrary": "Vyberte položku z plátna pre jej pridanie do knižnice.",
|
"hint_emptyPrivateLibrary": "Vyberte položku z plátna pre jej pridanie do knižnice.",
|
||||||
"search": {
|
"search": {
|
||||||
"inputPlaceholder": "",
|
"inputPlaceholder": "Prehľadávať knižnicu",
|
||||||
"heading": "",
|
"heading": "Výsledky z knižnice",
|
||||||
"noResults": "",
|
"noResults": "Neboli nájdené žiadne výsledky...",
|
||||||
"clearSearch": ""
|
"clearSearch": "Vymazať vyhľadávanie"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -195,8 +195,8 @@
|
|||||||
"singleResult": "výsledok",
|
"singleResult": "výsledok",
|
||||||
"multipleResults": "výsledky",
|
"multipleResults": "výsledky",
|
||||||
"placeholder": "Vyhľadať text v plátne...",
|
"placeholder": "Vyhľadať text v plátne...",
|
||||||
"frames": "",
|
"frames": "Rámce",
|
||||||
"texts": ""
|
"texts": "Texty"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Obnoviť plátno",
|
"clearReset": "Obnoviť plátno",
|
||||||
@@ -230,11 +230,11 @@
|
|||||||
"objectsSnapMode": "Prichytiť k objektom",
|
"objectsSnapMode": "Prichytiť k objektom",
|
||||||
"exitZenMode": "Zrušiť režim zen",
|
"exitZenMode": "Zrušiť režim zen",
|
||||||
"cancel": "Zrušiť",
|
"cancel": "Zrušiť",
|
||||||
"saveLibNames": "",
|
"saveLibNames": "Uložiť názvy a ukončiť",
|
||||||
"clear": "Vymazať",
|
"clear": "Vymazať",
|
||||||
"remove": "Odstrániť",
|
"remove": "Odstrániť",
|
||||||
"embed": "Prepnúť zapustenie",
|
"embed": "Prepnúť zapustenie",
|
||||||
"publishLibrary": "",
|
"publishLibrary": "Premenovať a zverejniť",
|
||||||
"submit": "Potvrdiť",
|
"submit": "Potvrdiť",
|
||||||
"confirm": "Potvrdiť",
|
"confirm": "Potvrdiť",
|
||||||
"embeddableInteractionButton": "Kliknite pre interakciu"
|
"embeddableInteractionButton": "Kliknite pre interakciu"
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
"removeItemsFromsLibrary": "Odstrániť {{count}} položiek z knižnice?",
|
"removeItemsFromsLibrary": "Odstrániť {{count}} položiek z knižnice?",
|
||||||
"invalidEncryptionKey": "Šifrovací kľúč musí mať 22 znakov. Živá spolupráca je vypnutá.",
|
"invalidEncryptionKey": "Šifrovací kľúč musí mať 22 znakov. Živá spolupráca je vypnutá.",
|
||||||
"collabOfflineWarning": "Internetové pripojenie nie je dostupné.\nVaše zmeny nebudú uložené!",
|
"collabOfflineWarning": "Internetové pripojenie nie je dostupné.\nVaše zmeny nebudú uložené!",
|
||||||
"localStorageQuotaExceeded": ""
|
"localStorageQuotaExceeded": "Pamäť prehliadača je plná. Zmeny nebudú uložené."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"unsupportedFileType": "Nepodporovaný typ súboru.",
|
"unsupportedFileType": "Nepodporovaný typ súboru.",
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Výber",
|
"selection": "Výber",
|
||||||
"lasso": "",
|
"lasso": "Laso výber",
|
||||||
"image": "Vložiť obrázok",
|
"image": "Vložiť obrázok",
|
||||||
"rectangle": "Obdĺžnik",
|
"rectangle": "Obdĺžnik",
|
||||||
"diamond": "Diamant",
|
"diamond": "Diamant",
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
"hand": "Ruka (nástroj pre pohyb plátna)",
|
"hand": "Ruka (nástroj pre pohyb plátna)",
|
||||||
"extraTools": "Ďalšie nástroje",
|
"extraTools": "Ďalšie nástroje",
|
||||||
"mermaidToExcalidraw": "Mermaid do Excalidraw",
|
"mermaidToExcalidraw": "Mermaid do Excalidraw",
|
||||||
"convertElementType": ""
|
"convertElementType": "Prepnúť typ tvaru"
|
||||||
},
|
},
|
||||||
"element": {
|
"element": {
|
||||||
"rectangle": "Obdĺžnik",
|
"rectangle": "Obdĺžnik",
|
||||||
@@ -337,34 +337,34 @@
|
|||||||
"shapes": "Tvary"
|
"shapes": "Tvary"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"dismissSearch": "",
|
"dismissSearch": "{{shortcut}} pre zrušenie vyhľadávania",
|
||||||
"canvasPanning": "",
|
"canvasPanning": "Pre pohyb plátna podržte {{shortcut_1}} alebo {{shortcut_2}} počas ťahania, alebo použite nástroj ruka",
|
||||||
"linearElement": "Kliknite na vloženie viacerých bodov, potiahnite na vytvorenie jednej priamky",
|
"linearElement": "Kliknite na vloženie viacerých bodov, potiahnite na vytvorenie jednej priamky",
|
||||||
"arrowTool": "",
|
"arrowTool": "Kliknite pre začatie pridávania viacerých bodov, potiahnite pre pridanie jednej čiary. Stlačte znovu {{shortcut}} pre zmenu typu šípky.",
|
||||||
"arrowBindModifiers": "",
|
"arrowBindModifiers": "Podržte {{shortcut_1}} na zrušenie prichytávania alebo {{shortcut_2}} na prichytávanie k pevnému bodu",
|
||||||
"freeDraw": "Kliknite a ťahajte, pustite na ukončenie",
|
"freeDraw": "Kliknite a ťahajte, pustite na ukončenie",
|
||||||
"text": "Tip: text môžete pridať aj dvojklikom kdekoľvek, ak je zvolený nástroj výber",
|
"text": "Tip: text môžete pridať aj dvojklikom kdekoľvek, ak je zvolený nástroj výber",
|
||||||
"embeddable": "Kliknite a ťahajte pre zapustenie webovej stránky",
|
"embeddable": "Kliknite a ťahajte pre zapustenie webovej stránky",
|
||||||
"text_selected": "",
|
"text_selected": "Použite dvojklik alebo stlačte {{shortcut}} na editovanie textu",
|
||||||
"text_editing": "",
|
"text_editing": "Stlačte {{shortcut_1}} alebo {{shortcut_2}} pre ukončenie editovania",
|
||||||
"linearElementMulti": "",
|
"linearElementMulti": "Kliknite na posledný bod alebo stačte {{shortcut_1}} alebo {{shortcut_2}} pre ukončenie",
|
||||||
"lockAngle": "",
|
"lockAngle": "Počas rotácie obmedzíte uhol podržaním {{shortcut}}",
|
||||||
"resize": "",
|
"resize": "Počas zmeny veľkosti zachováte proporcie podržaním {{shortcut_1}}, podržaním {{shortcut_2}} meníte veľkosť so zachovaním stredu",
|
||||||
"resizeImage": "",
|
"resizeImage": "Podržte {{shortcut_1}} pre voľnú zmenu veľkosti, podržte {{shortcut_2}} pre zmenu veľkosti od stredu",
|
||||||
"rotate": "",
|
"rotate": "Počas rotácie obmedzíte uhol podržaním {{shortcut}}",
|
||||||
"lineEditor_info": "",
|
"lineEditor_info": "Podržte {{shortcut_1}} a spravte dvojklik alebo stačte {{shortcut_2}} na editovanie bodov",
|
||||||
"lineEditor_line_info": "",
|
"lineEditor_line_info": "Použite dvojklik alebo stlačte {{shortcut}} na editovanie textu",
|
||||||
"lineEditor_pointSelected": "",
|
"lineEditor_pointSelected": "Stačte {{shortcut_1}} pre vymazanie bodov, {{shortcut_2}} pre ich duplikovanie alebo potiahnite pre ich posunutie",
|
||||||
"lineEditor_nothingSelected": "",
|
"lineEditor_nothingSelected": "Zvoľte bod na upravovanie (podržte {{shortcut_1}} pre zvolenie viacerých bodov) alebo podržte {{shortcut_2}} a kliknite na pridanie nového bodu",
|
||||||
"publishLibrary": "Uverejniť vašu knižnicu",
|
"publishLibrary": "Uverejniť vašu knižnicu",
|
||||||
"bindTextToElement": "",
|
"bindTextToElement": "{{shortcut}} na pridanie textu",
|
||||||
"createFlowchart": "",
|
"createFlowchart": "{{shortcut}} pre vytvorenie vývojového diagramu",
|
||||||
"deepBoxSelect": "",
|
"deepBoxSelect": "Podržte {{shortcut}} na výber v skupine alebo zamedzeniu potiahnutia",
|
||||||
"eraserRevert": "",
|
"eraserRevert": "Podržte {{shortcut}} pre prehodenie položiek určených na vymazanie",
|
||||||
"firefox_clipboard_write": "Táto sa funkcionalita sa dá zapnúť nastavením \"dom.events.asyncClipboard.clipboardItem\" na \"true\". Pre zmenu nastavení vo Firefox-e otvorte stránku \"about:config\".",
|
"firefox_clipboard_write": "Táto sa funkcionalita sa dá zapnúť nastavením \"dom.events.asyncClipboard.clipboardItem\" na \"true\". Pre zmenu nastavení vo Firefox-e otvorte stránku \"about:config\".",
|
||||||
"disableSnapping": "",
|
"disableSnapping": "Podržte {{shortcut}} pre vypnutie prichytávania",
|
||||||
"enterCropEditor": "",
|
"enterCropEditor": "Kliknite dva krát na obrázok alebo stlačte {{shortcut}} pre jeho orezanie",
|
||||||
"leaveCropEditor": ""
|
"leaveCropEditor": "Kliknite mimo obrázka, alebo stačte {{shortcut_1}} alebo {{shortcut_2}} pre ukončenie orezávania"
|
||||||
},
|
},
|
||||||
"canvasError": {
|
"canvasError": {
|
||||||
"cannotShowPreview": "Nie je možné zobraziť náhľad plátna",
|
"cannotShowPreview": "Nie je možné zobraziť náhľad plátna",
|
||||||
@@ -569,7 +569,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
"color": "",
|
"color": "Farba",
|
||||||
"mostUsedCustomColors": "Najpoužívanejšie vlastné farby",
|
"mostUsedCustomColors": "Najpoužívanejšie vlastné farby",
|
||||||
"colors": "Farby",
|
"colors": "Farby",
|
||||||
"shades": "Odtiene",
|
"shades": "Odtiene",
|
||||||
@@ -651,15 +651,15 @@
|
|||||||
"shortcutHint": "Použite paletu príkazov pomocou {{shortcut}}"
|
"shortcutHint": "Použite paletu príkazov pomocou {{shortcut}}"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"ctrl": "",
|
"ctrl": "Ctrl",
|
||||||
"option": "",
|
"option": "Option",
|
||||||
"cmd": "",
|
"cmd": "Cmd",
|
||||||
"alt": "",
|
"alt": "Alt",
|
||||||
"escape": "",
|
"escape": "Esc",
|
||||||
"enter": "",
|
"enter": "Enter",
|
||||||
"shift": "",
|
"shift": "Shift",
|
||||||
"spacebar": "",
|
"spacebar": "Medzerník",
|
||||||
"delete": "",
|
"delete": "Delete",
|
||||||
"mmb": ""
|
"mmb": "Rolovacie koliesko"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,13 +351,13 @@
|
|||||||
"lockAngle": "",
|
"lockAngle": "",
|
||||||
"resize": "",
|
"resize": "",
|
||||||
"resizeImage": "",
|
"resizeImage": "",
|
||||||
"rotate": "",
|
"rotate": "Giới hạn góc độ bằng cách giữ {{shortcut}} khi xoay",
|
||||||
"lineEditor_info": "",
|
"lineEditor_info": "",
|
||||||
"lineEditor_line_info": "",
|
"lineEditor_line_info": "",
|
||||||
"lineEditor_pointSelected": "",
|
"lineEditor_pointSelected": "",
|
||||||
"lineEditor_nothingSelected": "",
|
"lineEditor_nothingSelected": "",
|
||||||
"publishLibrary": "Chia sẻ thư viện của bạn",
|
"publishLibrary": "Chia sẻ thư viện của bạn",
|
||||||
"bindTextToElement": "",
|
"bindTextToElement": "Nhấn {{shortcut}} để thêm văn bản",
|
||||||
"createFlowchart": "",
|
"createFlowchart": "",
|
||||||
"deepBoxSelect": "",
|
"deepBoxSelect": "",
|
||||||
"eraserRevert": "",
|
"eraserRevert": "",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { THEME, THEME_FILTER } from "@excalidraw/common";
|
import { THEME, applyDarkModeFilter } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { StaticCanvasRenderConfig } from "../scene/types";
|
import type { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
import type { AppState, StaticCanvasAppState } from "../types";
|
import type { AppState, StaticCanvasAppState } from "../types";
|
||||||
@@ -51,10 +51,6 @@ export const bootstrapCanvas = ({
|
|||||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
context.scale(scale, scale);
|
context.scale(scale, scale);
|
||||||
|
|
||||||
if (isExporting && theme === THEME.DARK) {
|
|
||||||
context.filter = THEME_FILTER;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paint background
|
// Paint background
|
||||||
if (typeof viewBackgroundColor === "string") {
|
if (typeof viewBackgroundColor === "string") {
|
||||||
const hasTransparence =
|
const hasTransparence =
|
||||||
@@ -66,7 +62,10 @@ export const bootstrapCanvas = ({
|
|||||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
}
|
}
|
||||||
context.save();
|
context.save();
|
||||||
context.fillStyle = viewBackgroundColor;
|
context.fillStyle =
|
||||||
|
theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(viewBackgroundColor)
|
||||||
|
: viewBackgroundColor;
|
||||||
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
context.restore();
|
context.restore();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { FRAME_STYLE, throttleRAF } from "@excalidraw/common";
|
import {
|
||||||
|
applyDarkModeFilter,
|
||||||
|
FRAME_STYLE,
|
||||||
|
THEME,
|
||||||
|
throttleRAF,
|
||||||
|
} from "@excalidraw/common";
|
||||||
import { isElementLink } from "@excalidraw/element";
|
import { isElementLink } from "@excalidraw/element";
|
||||||
import { createPlaceholderEmbeddableLabel } from "@excalidraw/element";
|
import { createPlaceholderEmbeddableLabel } from "@excalidraw/element";
|
||||||
import { getBoundTextElement } from "@excalidraw/element";
|
import { getBoundTextElement } from "@excalidraw/element";
|
||||||
@@ -38,8 +43,14 @@ import type {
|
|||||||
import type { StaticCanvasAppState, Zoom } from "../types";
|
import type { StaticCanvasAppState, Zoom } from "../types";
|
||||||
|
|
||||||
const GridLineColor = {
|
const GridLineColor = {
|
||||||
Bold: "#dddddd",
|
[THEME.LIGHT]: {
|
||||||
Regular: "#e5e5e5",
|
bold: "#dddddd",
|
||||||
|
regular: "#e5e5e5",
|
||||||
|
},
|
||||||
|
[THEME.DARK]: {
|
||||||
|
bold: applyDarkModeFilter("#dddddd"),
|
||||||
|
regular: applyDarkModeFilter("#e5e5e5"),
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const strokeGrid = (
|
const strokeGrid = (
|
||||||
@@ -51,6 +62,7 @@ const strokeGrid = (
|
|||||||
scrollX: number,
|
scrollX: number,
|
||||||
scrollY: number,
|
scrollY: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
|
theme: StaticCanvasRenderConfig["theme"],
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
) => {
|
) => {
|
||||||
@@ -86,7 +98,9 @@ const strokeGrid = (
|
|||||||
|
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.setLineDash(isBold ? [] : lineDash);
|
context.setLineDash(isBold ? [] : lineDash);
|
||||||
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
|
context.strokeStyle = isBold
|
||||||
|
? GridLineColor[theme].bold
|
||||||
|
: GridLineColor[theme].regular;
|
||||||
context.moveTo(x, offsetY - gridSize);
|
context.moveTo(x, offsetY - gridSize);
|
||||||
context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
|
context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
|
||||||
context.stroke();
|
context.stroke();
|
||||||
@@ -105,7 +119,9 @@ const strokeGrid = (
|
|||||||
|
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.setLineDash(isBold ? [] : lineDash);
|
context.setLineDash(isBold ? [] : lineDash);
|
||||||
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
|
context.strokeStyle = isBold
|
||||||
|
? GridLineColor[theme].bold
|
||||||
|
: GridLineColor[theme].regular;
|
||||||
context.moveTo(offsetX - gridSize, y);
|
context.moveTo(offsetX - gridSize, y);
|
||||||
context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
|
context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
@@ -252,6 +268,7 @@ const _renderStaticScene = ({
|
|||||||
appState.scrollX,
|
appState.scrollX,
|
||||||
appState.scrollY,
|
appState.scrollY,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
|
renderConfig.theme,
|
||||||
normalizedWidth / appState.zoom.value,
|
normalizedWidth / appState.zoom.value,
|
||||||
normalizedHeight / appState.zoom.value,
|
normalizedHeight / appState.zoom.value,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
MIME_TYPES,
|
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
|
THEME,
|
||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
isRTL,
|
isRTL,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
|
applyDarkModeFilter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { normalizeLink, toValidURL } from "@excalidraw/common";
|
import { normalizeLink, toValidURL } from "@excalidraw/common";
|
||||||
import { hashString } from "@excalidraw/element";
|
import { hashString } from "@excalidraw/element";
|
||||||
@@ -31,8 +32,6 @@ import { getCornerRadius, isPathALoop } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { ShapeCache } from "@excalidraw/element";
|
import { ShapeCache } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -74,7 +73,7 @@ const maybeWrapNodesInFrameClipPath = (
|
|||||||
}
|
}
|
||||||
const frame = getContainingFrame(element, elementsMap);
|
const frame = getContainingFrame(element, elementsMap);
|
||||||
if (frame) {
|
if (frame) {
|
||||||
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
|
const g = root.ownerDocument.createElementNS(SVG_NS, "g");
|
||||||
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
|
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
|
||||||
nodes.forEach((node) => g.appendChild(node));
|
nodes.forEach((node) => g.appendChild(node));
|
||||||
return g;
|
return g;
|
||||||
@@ -120,7 +119,7 @@ const renderElementToSvg = (
|
|||||||
|
|
||||||
// if the element has a link, create an anchor tag and make that the new root
|
// if the element has a link, create an anchor tag and make that the new root
|
||||||
if (element.link) {
|
if (element.link) {
|
||||||
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
|
const anchorTag = svgRoot.ownerDocument.createElementNS(SVG_NS, "a");
|
||||||
anchorTag.setAttribute("href", normalizeLink(element.link));
|
anchorTag.setAttribute("href", normalizeLink(element.link));
|
||||||
root.appendChild(anchorTag);
|
root.appendChild(anchorTag);
|
||||||
root = anchorTag;
|
root = anchorTag;
|
||||||
@@ -147,7 +146,7 @@ const renderElementToSvg = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
case "ellipse": {
|
case "ellipse": {
|
||||||
const shape = ShapeCache.generateElementShape(element, null);
|
const shape = ShapeCache.generateElementShape(element, renderConfig);
|
||||||
const node = roughSVGDrawWithPrecision(
|
const node = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
shape,
|
shape,
|
||||||
@@ -242,7 +241,7 @@ const renderElementToSvg = (
|
|||||||
renderConfig.renderEmbeddables === false ||
|
renderConfig.renderEmbeddables === false ||
|
||||||
embedLink?.type === "document"
|
embedLink?.type === "document"
|
||||||
) {
|
) {
|
||||||
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
|
const anchorTag = svgRoot.ownerDocument.createElementNS(SVG_NS, "a");
|
||||||
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
|
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
|
||||||
anchorTag.setAttribute("target", "_blank");
|
anchorTag.setAttribute("target", "_blank");
|
||||||
anchorTag.setAttribute("rel", "noopener noreferrer");
|
anchorTag.setAttribute("rel", "noopener noreferrer");
|
||||||
@@ -250,18 +249,18 @@ const renderElementToSvg = (
|
|||||||
|
|
||||||
embeddableNode.appendChild(anchorTag);
|
embeddableNode.appendChild(anchorTag);
|
||||||
} else {
|
} else {
|
||||||
const foreignObject = svgRoot.ownerDocument!.createElementNS(
|
const foreignObject = svgRoot.ownerDocument.createElementNS(
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
"foreignObject",
|
"foreignObject",
|
||||||
);
|
);
|
||||||
foreignObject.style.width = `${element.width}px`;
|
foreignObject.style.width = `${element.width}px`;
|
||||||
foreignObject.style.height = `${element.height}px`;
|
foreignObject.style.height = `${element.height}px`;
|
||||||
foreignObject.style.border = "none";
|
foreignObject.style.border = "none";
|
||||||
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
|
const div = foreignObject.ownerDocument.createElementNS(SVG_NS, "div");
|
||||||
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
|
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
|
||||||
div.style.width = "100%";
|
div.style.width = "100%";
|
||||||
div.style.height = "100%";
|
div.style.height = "100%";
|
||||||
const iframe = div.ownerDocument!.createElement("iframe");
|
const iframe = div.ownerDocument.createElement("iframe");
|
||||||
iframe.src = embedLink?.link ?? "";
|
iframe.src = embedLink?.link ?? "";
|
||||||
iframe.style.width = "100%";
|
iframe.style.width = "100%";
|
||||||
iframe.style.height = "100%";
|
iframe.style.height = "100%";
|
||||||
@@ -281,10 +280,10 @@ const renderElementToSvg = (
|
|||||||
case "line":
|
case "line":
|
||||||
case "arrow": {
|
case "arrow": {
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
|
const maskPath = svgRoot.ownerDocument.createElementNS(SVG_NS, "mask");
|
||||||
if (boundText) {
|
if (boundText) {
|
||||||
maskPath.setAttribute("id", `mask-${element.id}`);
|
maskPath.setAttribute("id", `mask-${element.id}`);
|
||||||
const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
|
const maskRectVisible = svgRoot.ownerDocument.createElementNS(
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
"rect",
|
"rect",
|
||||||
);
|
);
|
||||||
@@ -303,7 +302,7 @@ const renderElementToSvg = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
maskPath.appendChild(maskRectVisible);
|
maskPath.appendChild(maskRectVisible);
|
||||||
const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
|
const maskRectInvisible = svgRoot.ownerDocument.createElementNS(
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
"rect",
|
"rect",
|
||||||
);
|
);
|
||||||
@@ -324,7 +323,7 @@ const renderElementToSvg = (
|
|||||||
maskRectInvisible.setAttribute("opacity", "1");
|
maskRectInvisible.setAttribute("opacity", "1");
|
||||||
maskPath.appendChild(maskRectInvisible);
|
maskPath.appendChild(maskRectInvisible);
|
||||||
}
|
}
|
||||||
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
const group = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
|
||||||
if (boundText) {
|
if (boundText) {
|
||||||
group.setAttribute("mask", `url(#mask-${element.id})`);
|
group.setAttribute("mask", `url(#mask-${element.id})`);
|
||||||
}
|
}
|
||||||
@@ -374,42 +373,63 @@ const renderElementToSvg = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
const backgroundFillShape = ShapeCache.generateElementShape(
|
const wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
|
||||||
element,
|
|
||||||
renderConfig,
|
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
||||||
);
|
// always ordered as [background, stroke]
|
||||||
const node = backgroundFillShape
|
for (const shape of shapes) {
|
||||||
? roughSVGDrawWithPrecision(
|
if (typeof shape === "string") {
|
||||||
|
// stroke (SVGPathString)
|
||||||
|
|
||||||
|
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
|
||||||
|
path.setAttribute(
|
||||||
|
"fill",
|
||||||
|
renderConfig.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor,
|
||||||
|
);
|
||||||
|
path.setAttribute("d", shape);
|
||||||
|
wrapper.appendChild(path);
|
||||||
|
} else {
|
||||||
|
// background (Drawable)
|
||||||
|
|
||||||
|
const bgNode = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
backgroundFillShape,
|
shape,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
)
|
);
|
||||||
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
|
||||||
if (opacity !== 1) {
|
// if children wrapped in <g>, unwrap it
|
||||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
if (bgNode.nodeName === "g") {
|
||||||
node.setAttribute("fill-opacity", `${opacity}`);
|
while (bgNode.firstChild) {
|
||||||
|
wrapper.appendChild(bgNode.firstChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wrapper.appendChild(bgNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
node.setAttribute(
|
if (opacity !== 1) {
|
||||||
|
wrapper.setAttribute("stroke-opacity", `${opacity}`);
|
||||||
|
wrapper.setAttribute("fill-opacity", `${opacity}`);
|
||||||
|
}
|
||||||
|
wrapper.setAttribute(
|
||||||
"transform",
|
"transform",
|
||||||
`translate(${offsetX || 0} ${
|
`translate(${offsetX || 0} ${
|
||||||
offsetY || 0
|
offsetY || 0
|
||||||
}) rotate(${degree} ${cx} ${cy})`,
|
}) rotate(${degree} ${cx} ${cy})`,
|
||||||
);
|
);
|
||||||
node.setAttribute("stroke", "none");
|
wrapper.setAttribute("stroke", "none");
|
||||||
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
|
|
||||||
path.setAttribute("fill", element.strokeColor);
|
|
||||||
path.setAttribute("d", getFreeDrawSvgPath(element));
|
|
||||||
node.appendChild(path);
|
|
||||||
|
|
||||||
const g = maybeWrapNodesInFrameClipPath(
|
const g = maybeWrapNodesInFrameClipPath(
|
||||||
element,
|
element,
|
||||||
root,
|
root,
|
||||||
[node],
|
[wrapper],
|
||||||
renderConfig.frameRendering,
|
renderConfig.frameRendering,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
addToRoot(g || node, element);
|
addToRoot(g || wrapper, element);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "image": {
|
case "image": {
|
||||||
@@ -439,10 +459,10 @@ const renderElementToSvg = (
|
|||||||
|
|
||||||
let symbol = svgRoot.querySelector(`#${symbolId}`);
|
let symbol = svgRoot.querySelector(`#${symbolId}`);
|
||||||
if (!symbol) {
|
if (!symbol) {
|
||||||
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
|
symbol = svgRoot.ownerDocument.createElementNS(SVG_NS, "symbol");
|
||||||
symbol.id = symbolId;
|
symbol.id = symbolId;
|
||||||
|
|
||||||
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
|
const image = svgRoot.ownerDocument.createElementNS(SVG_NS, "image");
|
||||||
image.setAttribute("href", fileData.dataURL);
|
image.setAttribute("href", fileData.dataURL);
|
||||||
image.setAttribute("preserveAspectRatio", "none");
|
image.setAttribute("preserveAspectRatio", "none");
|
||||||
|
|
||||||
@@ -459,17 +479,9 @@ const renderElementToSvg = (
|
|||||||
(root.querySelector("defs") || root).prepend(symbol);
|
(root.querySelector("defs") || root).prepend(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
const use = svgRoot.ownerDocument.createElementNS(SVG_NS, "use");
|
||||||
use.setAttribute("href", `#${symbolId}`);
|
use.setAttribute("href", `#${symbolId}`);
|
||||||
|
|
||||||
// in dark theme, revert the image color filter
|
|
||||||
if (
|
|
||||||
renderConfig.exportWithDarkMode &&
|
|
||||||
fileData.mimeType !== MIME_TYPES.svg
|
|
||||||
) {
|
|
||||||
use.setAttribute("filter", IMAGE_INVERT_FILTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedCropX = 0;
|
let normalizedCropX = 0;
|
||||||
let normalizedCropY = 0;
|
let normalizedCropY = 0;
|
||||||
|
|
||||||
@@ -506,13 +518,13 @@ const renderElementToSvg = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
const g = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
|
||||||
|
|
||||||
if (element.crop) {
|
if (element.crop) {
|
||||||
const mask = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
|
const mask = svgRoot.ownerDocument.createElementNS(SVG_NS, "mask");
|
||||||
mask.setAttribute("id", `mask-image-crop-${element.id}`);
|
mask.setAttribute("id", `mask-image-crop-${element.id}`);
|
||||||
mask.setAttribute("fill", "#fff");
|
mask.setAttribute("fill", "#fff");
|
||||||
const maskRect = svgRoot.ownerDocument!.createElementNS(
|
const maskRect = svgRoot.ownerDocument.createElementNS(
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
"rect",
|
"rect",
|
||||||
);
|
);
|
||||||
@@ -536,13 +548,13 @@ const renderElementToSvg = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (element.roundness) {
|
if (element.roundness) {
|
||||||
const clipPath = svgRoot.ownerDocument!.createElementNS(
|
const clipPath = svgRoot.ownerDocument.createElementNS(
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
"clipPath",
|
"clipPath",
|
||||||
);
|
);
|
||||||
clipPath.id = `image-clipPath-${element.id}`;
|
clipPath.id = `image-clipPath-${element.id}`;
|
||||||
clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
|
clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
|
||||||
const clipRect = svgRoot.ownerDocument!.createElementNS(
|
const clipRect = svgRoot.ownerDocument.createElementNS(
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
"rect",
|
"rect",
|
||||||
);
|
);
|
||||||
@@ -598,7 +610,12 @@ const renderElementToSvg = (
|
|||||||
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
|
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
|
||||||
|
|
||||||
rect.setAttribute("fill", "none");
|
rect.setAttribute("fill", "none");
|
||||||
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
|
rect.setAttribute(
|
||||||
|
"stroke",
|
||||||
|
renderConfig.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
||||||
|
: FRAME_STYLE.strokeColor,
|
||||||
|
);
|
||||||
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
|
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
|
||||||
|
|
||||||
addToRoot(rect, element);
|
addToRoot(rect, element);
|
||||||
@@ -607,7 +624,7 @@ const renderElementToSvg = (
|
|||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
const node = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
|
||||||
if (opacity !== 1) {
|
if (opacity !== 1) {
|
||||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||||
node.setAttribute("fill-opacity", `${opacity}`);
|
node.setAttribute("fill-opacity", `${opacity}`);
|
||||||
@@ -643,13 +660,18 @@ const renderElementToSvg = (
|
|||||||
? "end"
|
? "end"
|
||||||
: "start";
|
: "start";
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
|
const text = svgRoot.ownerDocument.createElementNS(SVG_NS, "text");
|
||||||
text.textContent = lines[i];
|
text.textContent = lines[i];
|
||||||
text.setAttribute("x", `${horizontalOffset}`);
|
text.setAttribute("x", `${horizontalOffset}`);
|
||||||
text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
|
text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
|
||||||
text.setAttribute("font-family", getFontFamilyString(element));
|
text.setAttribute("font-family", getFontFamilyString(element));
|
||||||
text.setAttribute("font-size", `${element.fontSize}px`);
|
text.setAttribute("font-size", `${element.fontSize}px`);
|
||||||
text.setAttribute("fill", element.strokeColor);
|
text.setAttribute(
|
||||||
|
"fill",
|
||||||
|
renderConfig.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor,
|
||||||
|
);
|
||||||
text.setAttribute("text-anchor", textAnchor);
|
text.setAttribute("text-anchor", textAnchor);
|
||||||
text.setAttribute("style", "white-space: pre;");
|
text.setAttribute("style", "white-space: pre;");
|
||||||
text.setAttribute("direction", direction);
|
text.setAttribute("direction", direction);
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
THEME,
|
THEME,
|
||||||
THEME_FILTER,
|
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
EXPORT_DATA_TYPES,
|
EXPORT_DATA_TYPES,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
distance,
|
distance,
|
||||||
getFontString,
|
getFontString,
|
||||||
toBrandedType,
|
toBrandedType,
|
||||||
|
applyDarkModeFilter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
||||||
@@ -268,6 +268,7 @@ export const exportToCanvas = async (
|
|||||||
embedsValidationStatus: new Map(),
|
embedsValidationStatus: new Map(),
|
||||||
elementsPendingErasure: new Set(),
|
elementsPendingErasure: new Set(),
|
||||||
pendingFlowchartNodes: null,
|
pendingFlowchartNodes: null,
|
||||||
|
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,9 +349,6 @@ export const exportToSvg = async (
|
|||||||
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
svgRoot.setAttribute("width", `${width * exportScale}`);
|
svgRoot.setAttribute("width", `${width * exportScale}`);
|
||||||
svgRoot.setAttribute("height", `${height * exportScale}`);
|
svgRoot.setAttribute("height", `${height * exportScale}`);
|
||||||
if (exportWithDarkMode) {
|
|
||||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
|
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
|
||||||
|
|
||||||
@@ -455,7 +453,12 @@ export const exportToSvg = async (
|
|||||||
rect.setAttribute("y", "0");
|
rect.setAttribute("y", "0");
|
||||||
rect.setAttribute("width", `${width}`);
|
rect.setAttribute("width", `${width}`);
|
||||||
rect.setAttribute("height", `${height}`);
|
rect.setAttribute("height", `${height}`);
|
||||||
rect.setAttribute("fill", viewBackgroundColor);
|
rect.setAttribute(
|
||||||
|
"fill",
|
||||||
|
exportWithDarkMode
|
||||||
|
? applyDarkModeFilter(viewBackgroundColor)
|
||||||
|
: viewBackgroundColor,
|
||||||
|
);
|
||||||
svgRoot.appendChild(rect);
|
svgRoot.appendChild(rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +492,7 @@ export const exportToSvg = async (
|
|||||||
)
|
)
|
||||||
: new Map(),
|
: new Map(),
|
||||||
reuseImages: opts?.reuseImages ?? true,
|
reuseImages: opts?.reuseImages ?? true,
|
||||||
|
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type StaticCanvasRenderConfig = {
|
|||||||
embedsValidationStatus: EmbedsValidationStatus;
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
elementsPendingErasure: ElementsPendingErasure;
|
elementsPendingErasure: ElementsPendingErasure;
|
||||||
pendingFlowchartNodes: PendingExcalidrawElements | null;
|
pendingFlowchartNodes: PendingExcalidrawElements | null;
|
||||||
|
theme: AppState["theme"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SVGRenderConfig = {
|
export type SVGRenderConfig = {
|
||||||
@@ -54,6 +55,7 @@ export type SVGRenderConfig = {
|
|||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
reuseImages: boolean;
|
reuseImages: boolean;
|
||||||
|
theme: AppState["theme"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InteractiveCanvasRenderConfig = {
|
export type InteractiveCanvasRenderConfig = {
|
||||||
@@ -148,7 +150,14 @@ export type ScrollBars = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ElementShape = Drawable | Drawable[] | null;
|
export type SVGPathString = string & { __brand: "SVGPathString" };
|
||||||
|
|
||||||
|
export type ElementShape =
|
||||||
|
| Drawable
|
||||||
|
| Drawable[]
|
||||||
|
| Path2D
|
||||||
|
| (Drawable | SVGPathString)[]
|
||||||
|
| null;
|
||||||
|
|
||||||
export type ElementShapes = {
|
export type ElementShapes = {
|
||||||
rectangle: Drawable;
|
rectangle: Drawable;
|
||||||
@@ -156,7 +165,7 @@ export type ElementShapes = {
|
|||||||
diamond: Drawable;
|
diamond: Drawable;
|
||||||
iframe: Drawable;
|
iframe: Drawable;
|
||||||
embeddable: Drawable;
|
embeddable: Drawable;
|
||||||
freedraw: Drawable | null;
|
freedraw: (Drawable | SVGPathString)[];
|
||||||
arrow: Drawable[];
|
arrow: Drawable[];
|
||||||
line: Drawable[];
|
line: Drawable[];
|
||||||
text: null;
|
text: null;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { normalizeInputColor } from "../components/ColorPicker/ColorInput";
|
||||||
|
|
||||||
|
describe("normalizeInputColor", () => {
|
||||||
|
describe("hex colors", () => {
|
||||||
|
it("returns hex color with hash as-is", () => {
|
||||||
|
expect(normalizeInputColor("#ff0000")).toBe("#ff0000");
|
||||||
|
expect(normalizeInputColor("#FF0000")).toBe("#FF0000");
|
||||||
|
expect(normalizeInputColor("#abc")).toBe("#abc");
|
||||||
|
expect(normalizeInputColor("#ABC")).toBe("#ABC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds hash to hex color without hash", () => {
|
||||||
|
expect(normalizeInputColor("ff0000")).toBe("#ff0000");
|
||||||
|
expect(normalizeInputColor("FF0000")).toBe("#FF0000");
|
||||||
|
expect(normalizeInputColor("abc")).toBe("#abc");
|
||||||
|
expect(normalizeInputColor("ABC")).toBe("#ABC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles 8-digit hex (hexa) with alpha", () => {
|
||||||
|
expect(normalizeInputColor("#ff000080")).toBe("#ff000080");
|
||||||
|
expect(normalizeInputColor("#ff0000ff")).toBe("#ff0000ff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT add hash to hexa without hash (tinycolor detects as hex8, not hex)", () => {
|
||||||
|
// Note: tinycolor detects 8-digit hex as "hex8" format, not "hex",
|
||||||
|
// so the hash prefix logic doesn't apply
|
||||||
|
expect(normalizeInputColor("ff000080")).toBe("ff000080");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("named colors", () => {
|
||||||
|
it("returns named colors as-is", () => {
|
||||||
|
expect(normalizeInputColor("red")).toBe("red");
|
||||||
|
expect(normalizeInputColor("blue")).toBe("blue");
|
||||||
|
expect(normalizeInputColor("green")).toBe("green");
|
||||||
|
expect(normalizeInputColor("white")).toBe("white");
|
||||||
|
expect(normalizeInputColor("black")).toBe("black");
|
||||||
|
expect(normalizeInputColor("transparent")).toBe("transparent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles case variations of named colors", () => {
|
||||||
|
expect(normalizeInputColor("RED")).toBe("RED");
|
||||||
|
expect(normalizeInputColor("Red")).toBe("Red");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rgb/rgba colors", () => {
|
||||||
|
it("returns rgb colors as-is", () => {
|
||||||
|
expect(normalizeInputColor("rgb(255, 0, 0)")).toBe("rgb(255, 0, 0)");
|
||||||
|
expect(normalizeInputColor("rgb(0,0,0)")).toBe("rgb(0,0,0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: tinycolor clamps values, so rgb(256, 0, 0) is treated as valid
|
||||||
|
it("tinycolor considers out-of-range rgb values as valid (clamped)", () => {
|
||||||
|
expect(normalizeInputColor("rgb(256, 0, 0)")).toBe("rgb(256, 0, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns rgba colors as-is", () => {
|
||||||
|
expect(normalizeInputColor("rgba(255, 0, 0, 0.5)")).toBe(
|
||||||
|
"rgba(255, 0, 0, 0.5)",
|
||||||
|
);
|
||||||
|
expect(normalizeInputColor("rgba(0,0,0,1)")).toBe("rgba(0,0,0,1)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hsl/hsla colors", () => {
|
||||||
|
it("returns hsl colors as-is", () => {
|
||||||
|
expect(normalizeInputColor("hsl(0, 100%, 50%)")).toBe(
|
||||||
|
"hsl(0, 100%, 50%)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hsla colors as-is", () => {
|
||||||
|
expect(normalizeInputColor("hsla(0, 100%, 50%, 0.5)")).toBe(
|
||||||
|
"hsla(0, 100%, 50%, 0.5)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("whitespace handling", () => {
|
||||||
|
it("trims leading whitespace", () => {
|
||||||
|
expect(normalizeInputColor(" #ff0000")).toBe("#ff0000");
|
||||||
|
expect(normalizeInputColor(" red")).toBe("red");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims trailing whitespace", () => {
|
||||||
|
expect(normalizeInputColor("#ff0000 ")).toBe("#ff0000");
|
||||||
|
expect(normalizeInputColor("red ")).toBe("red");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims both leading and trailing whitespace", () => {
|
||||||
|
expect(normalizeInputColor(" #ff0000 ")).toBe("#ff0000");
|
||||||
|
expect(normalizeInputColor(" red ")).toBe("red");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds hash to trimmed hex without hash", () => {
|
||||||
|
expect(normalizeInputColor(" ff0000 ")).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid colors", () => {
|
||||||
|
it("returns null for invalid color strings", () => {
|
||||||
|
expect(normalizeInputColor("notacolor")).toBe(null);
|
||||||
|
expect(normalizeInputColor("gggggg")).toBe(null);
|
||||||
|
expect(normalizeInputColor("#gggggg")).toBe(null);
|
||||||
|
expect(normalizeInputColor("")).toBe(null);
|
||||||
|
expect(normalizeInputColor(" ")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for partial/malformed colors", () => {
|
||||||
|
expect(normalizeInputColor("#ff")).toBe(null);
|
||||||
|
expect(normalizeInputColor("rgb(")).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,20 @@ describe("restoreElements", () => {
|
|||||||
mockSizeHelper.mockRestore();
|
mockSizeHelper.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("basic restoreElements", () => {
|
||||||
|
const textElement = API.createElement({ type: "text" });
|
||||||
|
const rectElement = API.createElement({ type: "rectangle" });
|
||||||
|
const elements = [textElement, rectElement];
|
||||||
|
|
||||||
|
const restoredElements = restore.restoreElements(elements, null);
|
||||||
|
expect(restoredElements.length).toBe(elements.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when imported data state is null it should return an empty array of elements", () => {
|
||||||
|
const restoredElements = restore.restoreElements(null, null);
|
||||||
|
expect(restoredElements.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return empty array when element is null", () => {
|
it("should return empty array when element is null", () => {
|
||||||
expect(restore.restoreElements(null, null)).toStrictEqual([]);
|
expect(restore.restoreElements(null, null)).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
@@ -434,12 +448,12 @@ describe("restoreElements", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("bump versions of local duplicate elements when supplied", () => {
|
it("bump versions of local duplicate elements when supplied", () => {
|
||||||
const rectangle = API.createElement({ type: "rectangle" });
|
const rectangle = API.createElement({ type: "rectangle" }); // version=1
|
||||||
const ellipse = API.createElement({ type: "ellipse" });
|
const ellipse = API.createElement({ type: "ellipse" });
|
||||||
const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
|
const rectangle_modified = newElementWith(rectangle, { isDeleted: true }); // version=2
|
||||||
|
|
||||||
const restoredElements = restore.restoreElements(
|
const restoredElements = restore.bumpElementVersions(
|
||||||
[rectangle, ellipse],
|
restore.restoreElements([rectangle, ellipse], null),
|
||||||
[rectangle_modified],
|
[rectangle_modified],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -448,7 +462,7 @@ describe("restoreElements", () => {
|
|||||||
expect(restoredElements).toEqual([
|
expect(restoredElements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rectangle.id,
|
id: rectangle.id,
|
||||||
version: rectangle_modified.version + 2,
|
version: rectangle_modified.version + 1,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: ellipse.id,
|
id: ellipse.id,
|
||||||
@@ -456,9 +470,73 @@ describe("restoreElements", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bump versions of local duplicate elements when supplied even if both have same version", () => {
|
||||||
|
const rectangle = API.createElement({ type: "rectangle" });
|
||||||
|
|
||||||
|
const restored_rectangle_1 = restore.restoreElements([rectangle], null)[0];
|
||||||
|
const restored_rectangle_2 = restore.restoreElements(
|
||||||
|
[restored_rectangle_1],
|
||||||
|
null,
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
// restored rectangle version should be +1 because of re-index
|
||||||
|
expect(rectangle.version).not.toBe(restored_rectangle_1.version);
|
||||||
|
|
||||||
|
// restoring it again shouldn't re-index again
|
||||||
|
expect(restored_rectangle_1.version).toBe(restored_rectangle_2.version);
|
||||||
|
expect(restored_rectangle_1.versionNonce).toBe(
|
||||||
|
restored_rectangle_2.versionNonce,
|
||||||
|
);
|
||||||
|
|
||||||
|
const modified_rectangle_1 = newElementWith(restored_rectangle_1, {
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
const modified_rectangle_2 = newElementWith(restored_rectangle_2, {
|
||||||
|
width: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElements = restore.bumpElementVersions(
|
||||||
|
restore.restoreElements([modified_rectangle_1], null),
|
||||||
|
[modified_rectangle_2],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restoredElements[0].id).toBe(rectangle.id);
|
||||||
|
expect(restoredElements[0].id).toBe(modified_rectangle_1.id);
|
||||||
|
expect(restoredElements[0].versionNonce).not.toBe(
|
||||||
|
modified_rectangle_1.versionNonce,
|
||||||
|
);
|
||||||
|
expect(restoredElements[0].version).toBe(modified_rectangle_2.version + 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("restoreAppState", () => {
|
describe("restoreAppState", () => {
|
||||||
|
it("when appState is null it should return the local app state property", () => {
|
||||||
|
const stubLocalAppState = getDefaultAppState();
|
||||||
|
stubLocalAppState.cursorButton = "down";
|
||||||
|
stubLocalAppState.name = "local app state";
|
||||||
|
|
||||||
|
const restoredAppState = restore.restoreAppState(null, stubLocalAppState);
|
||||||
|
expect(restoredAppState.cursorButton).toBe(stubLocalAppState.cursorButton);
|
||||||
|
expect(restoredAppState.name).toBe(stubLocalAppState.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when local appState is null but imported app state is supplied", () => {
|
||||||
|
const stubImportedAppState = getDefaultAppState();
|
||||||
|
stubImportedAppState.cursorButton = "down";
|
||||||
|
stubImportedAppState.name = "imported app state";
|
||||||
|
|
||||||
|
const importedDataState = {} as ImportedDataState;
|
||||||
|
importedDataState.appState = stubImportedAppState;
|
||||||
|
|
||||||
|
const restoredAppState = restore.restoreAppState(
|
||||||
|
importedDataState.appState,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(restoredAppState.cursorButton).toBe("up");
|
||||||
|
expect(restoredAppState.name).toBe(stubImportedAppState.name);
|
||||||
|
});
|
||||||
|
|
||||||
it("should restore with imported data", () => {
|
it("should restore with imported data", () => {
|
||||||
const stubImportedAppState = getDefaultAppState();
|
const stubImportedAppState = getDefaultAppState();
|
||||||
stubImportedAppState.activeTool.type = "selection";
|
stubImportedAppState.activeTool.type = "selection";
|
||||||
@@ -638,83 +716,6 @@ describe("restoreAppState", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("restore", () => {
|
|
||||||
it("when imported data state is null it should return an empty array of elements", () => {
|
|
||||||
const stubLocalAppState = getDefaultAppState();
|
|
||||||
|
|
||||||
const restoredData = restore.restore(null, stubLocalAppState, null);
|
|
||||||
expect(restoredData.elements.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("when imported data state is null it should return the local app state property", () => {
|
|
||||||
const stubLocalAppState = getDefaultAppState();
|
|
||||||
stubLocalAppState.cursorButton = "down";
|
|
||||||
stubLocalAppState.name = "local app state";
|
|
||||||
|
|
||||||
const restoredData = restore.restore(null, stubLocalAppState, null);
|
|
||||||
expect(restoredData.appState.cursorButton).toBe(
|
|
||||||
stubLocalAppState.cursorButton,
|
|
||||||
);
|
|
||||||
expect(restoredData.appState.name).toBe(stubLocalAppState.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("when imported data state has elements", () => {
|
|
||||||
const stubLocalAppState = getDefaultAppState();
|
|
||||||
|
|
||||||
const textElement = API.createElement({ type: "text" });
|
|
||||||
const rectElement = API.createElement({ type: "rectangle" });
|
|
||||||
const elements = [textElement, rectElement];
|
|
||||||
|
|
||||||
const importedDataState = {} as ImportedDataState;
|
|
||||||
importedDataState.elements = elements;
|
|
||||||
|
|
||||||
const restoredData = restore.restore(
|
|
||||||
importedDataState,
|
|
||||||
stubLocalAppState,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
expect(restoredData.elements.length).toBe(elements.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("when local app state is null but imported app state is supplied", () => {
|
|
||||||
const stubImportedAppState = getDefaultAppState();
|
|
||||||
stubImportedAppState.cursorButton = "down";
|
|
||||||
stubImportedAppState.name = "imported app state";
|
|
||||||
|
|
||||||
const importedDataState = {} as ImportedDataState;
|
|
||||||
importedDataState.appState = stubImportedAppState;
|
|
||||||
|
|
||||||
const restoredData = restore.restore(importedDataState, null, null);
|
|
||||||
expect(restoredData.appState.cursorButton).toBe("up");
|
|
||||||
expect(restoredData.appState.name).toBe(stubImportedAppState.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bump versions of local duplicate elements when supplied", () => {
|
|
||||||
const rectangle = API.createElement({ type: "rectangle" });
|
|
||||||
const ellipse = API.createElement({ type: "ellipse" });
|
|
||||||
|
|
||||||
const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
|
|
||||||
|
|
||||||
const restoredData = restore.restore(
|
|
||||||
{ elements: [rectangle, ellipse] },
|
|
||||||
null,
|
|
||||||
[rectangle_modified],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(restoredData.elements[0].id).toBe(rectangle.id);
|
|
||||||
expect(restoredData.elements[0].versionNonce).not.toBe(
|
|
||||||
rectangle.versionNonce,
|
|
||||||
);
|
|
||||||
expect(restoredData.elements).toEqual([
|
|
||||||
expect.objectContaining({ version: rectangle_modified.version + 2 }),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: ellipse.id,
|
|
||||||
version: ellipse.version + 1,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("repairing bindings", () => {
|
describe("repairing bindings", () => {
|
||||||
it("should repair container boundElements when repair is true", () => {
|
it("should repair container boundElements when repair is true", () => {
|
||||||
const container = API.createElement({
|
const container = API.createElement({
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const textFixture: ExcalidrawElement = {
|
|||||||
type: "text",
|
type: "text",
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
strokeColor: "#1e1e1e",
|
||||||
text: "original text",
|
text: "original text",
|
||||||
originalText: "original text",
|
originalText: "original text",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,10 @@
|
|||||||
import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
|
import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
|
||||||
|
|
||||||
import { FONT_FAMILY, FRAME_STYLE } from "@excalidraw/common";
|
import {
|
||||||
|
applyDarkModeFilter,
|
||||||
|
FONT_FAMILY,
|
||||||
|
FRAME_STYLE,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -116,9 +120,15 @@ describe("exportToSvg", () => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
|
const textElements = svgElement.querySelectorAll("text");
|
||||||
`"invert(93%) hue-rotate(180deg)"`,
|
expect(textElements.length).toBeGreaterThan(0);
|
||||||
);
|
|
||||||
|
textElements.forEach((textEl) => {
|
||||||
|
// fill color should be inverted in dark mode
|
||||||
|
expect(textEl.getAttribute("fill")).toBe(
|
||||||
|
applyDarkModeFilter(textFixture.strokeColor),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with exportPadding", async () => {
|
it("with exportPadding", async () => {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import {
|
|||||||
KEYS,
|
KEYS,
|
||||||
CLASSES,
|
CLASSES,
|
||||||
POINTER_BUTTON,
|
POINTER_BUTTON,
|
||||||
|
THEME,
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
getFontString,
|
getFontString,
|
||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
|
applyDarkModeFilter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -260,9 +262,11 @@ export const textWysiwyg = ({
|
|||||||
),
|
),
|
||||||
textAlign,
|
textAlign,
|
||||||
verticalAlign,
|
verticalAlign,
|
||||||
color: updatedTextElement.strokeColor,
|
color:
|
||||||
|
appState.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(updatedTextElement.strokeColor)
|
||||||
|
: updatedTextElement.strokeColor,
|
||||||
opacity: updatedTextElement.opacity / 100,
|
opacity: updatedTextElement.opacity / 100,
|
||||||
filter: "var(--theme-filter)",
|
|
||||||
maxHeight: `${editorMaxHeight}px`,
|
maxHeight: `${editorMaxHeight}px`,
|
||||||
});
|
});
|
||||||
editable.scrollTop = 0;
|
editable.scrollTop = 0;
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/clipboard";
|
} from "@excalidraw/excalidraw/clipboard";
|
||||||
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
|
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
import {
|
||||||
|
restoreAppState,
|
||||||
|
restoreElements,
|
||||||
|
} from "@excalidraw/excalidraw/data/restore";
|
||||||
import {
|
import {
|
||||||
exportToCanvas as _exportToCanvas,
|
exportToCanvas as _exportToCanvas,
|
||||||
exportToSvg as _exportToSvg,
|
exportToSvg as _exportToSvg,
|
||||||
@@ -45,12 +48,11 @@ export const exportToCanvas = ({
|
|||||||
}: ExportOpts & {
|
}: ExportOpts & {
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const { elements: restoredElements, appState: restoredAppState } = restore(
|
const restoredElements = restoreElements(elements, null, {
|
||||||
{ elements, appState },
|
deleteInvisibleElements: true,
|
||||||
null,
|
});
|
||||||
null,
|
const restoredAppState = restoreAppState(appState, null);
|
||||||
{ deleteInvisibleElements: true },
|
|
||||||
);
|
|
||||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||||
return _exportToCanvas(
|
return _exportToCanvas(
|
||||||
restoredElements,
|
restoredElements,
|
||||||
@@ -176,12 +178,10 @@ export const exportToSvg = async ({
|
|||||||
skipInliningFonts?: true;
|
skipInliningFonts?: true;
|
||||||
reuseImages?: boolean;
|
reuseImages?: boolean;
|
||||||
}): Promise<SVGSVGElement> => {
|
}): Promise<SVGSVGElement> => {
|
||||||
const { elements: restoredElements, appState: restoredAppState } = restore(
|
const restoredElements = restoreElements(elements, null, {
|
||||||
{ elements, appState },
|
deleteInvisibleElements: true,
|
||||||
null,
|
});
|
||||||
null,
|
const restoredAppState = restoreAppState(appState, null);
|
||||||
{ deleteInvisibleElements: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const exportAppState = {
|
const exportAppState = {
|
||||||
...restoredAppState,
|
...restoredAppState,
|
||||||
|
|||||||
@@ -3009,6 +3009,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
socket.io-client "*"
|
socket.io-client "*"
|
||||||
|
|
||||||
|
"@types/tinycolor2@1.4.6":
|
||||||
|
version "1.4.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz#670cbc0caf4e58dd61d1e3a6f26386e473087f06"
|
||||||
|
integrity sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==
|
||||||
|
|
||||||
"@types/trusted-types@^2.0.2":
|
"@types/trusted-types@^2.0.2":
|
||||||
version "2.0.7"
|
version "2.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
|
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
|
||||||
@@ -7877,6 +7882,14 @@ points-on-path@^0.2.1:
|
|||||||
path-data-parser "0.1.0"
|
path-data-parser "0.1.0"
|
||||||
points-on-curve "0.2.0"
|
points-on-curve "0.2.0"
|
||||||
|
|
||||||
|
polygon-clipping@^0.15.7:
|
||||||
|
version "0.15.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/polygon-clipping/-/polygon-clipping-0.15.7.tgz#3823ca1e372566f350795ce9dd9a7b19e97bdaad"
|
||||||
|
integrity sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==
|
||||||
|
dependencies:
|
||||||
|
robust-predicates "^3.0.2"
|
||||||
|
splaytree "^3.1.0"
|
||||||
|
|
||||||
portfinder@^1.0.28:
|
portfinder@^1.0.28:
|
||||||
version "1.0.33"
|
version "1.0.33"
|
||||||
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.33.tgz#03dbc51455aa8f83ad9fb86af8345e063bb51101"
|
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.33.tgz#03dbc51455aa8f83ad9fb86af8345e063bb51101"
|
||||||
@@ -8756,6 +8769,11 @@ sourcemap-codec@^1.4.8:
|
|||||||
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||||
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||||
|
|
||||||
|
splaytree@^3.1.0:
|
||||||
|
version "3.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/splaytree/-/splaytree-3.2.3.tgz#5e4836bc54cc939344bba8dc826f82fbde453106"
|
||||||
|
integrity sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==
|
||||||
|
|
||||||
split.js@^1.6.0:
|
split.js@^1.6.0:
|
||||||
version "1.6.5"
|
version "1.6.5"
|
||||||
resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300"
|
resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300"
|
||||||
@@ -9098,6 +9116,11 @@ tinybench@^2.9.0:
|
|||||||
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
|
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
|
||||||
integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
|
integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
|
||||||
|
|
||||||
|
tinycolor2@1.6.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
|
||||||
|
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
|
||||||
|
|
||||||
tinyexec@^0.3.2:
|
tinyexec@^0.3.2:
|
||||||
version "0.3.2"
|
version "0.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
|
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
|
||||||
@@ -9409,6 +9432,11 @@ update-browserslist-db@^1.1.1:
|
|||||||
escalade "^3.2.0"
|
escalade "^3.2.0"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
|
|
||||||
|
uqr@0.1.2:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/uqr/-/uqr-0.1.2.tgz#5c6cd5dcff9581f9bb35b982cb89e2c483a41d7d"
|
||||||
|
integrity sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==
|
||||||
|
|
||||||
uri-js@^4.2.2:
|
uri-js@^4.2.2:
|
||||||
version "4.4.1"
|
version "4.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
||||||
|
|||||||
Reference in New Issue
Block a user