sync intermediate text updates (#1174)
* sync intermediate text updates * fix initial render text position * batch updates * tweak onChange subscription
This commit is contained in:
parent
0c9459e9e5
commit
4912a29e75
@ -46,11 +46,14 @@ import {
|
|||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
exportCanvas,
|
exportCanvas,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import { restore } from "../data/restore";
|
|
||||||
|
|
||||||
import { renderScene } from "../renderer";
|
import { renderScene } from "../renderer";
|
||||||
import { AppState, GestureEvent, Gesture } from "../types";
|
import { AppState, GestureEvent, Gesture } from "../types";
|
||||||
import { ExcalidrawElement, ExcalidrawLinearElement } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../element/types";
|
||||||
import { rotate, adjustXYWithRotation } from "../math";
|
import { rotate, adjustXYWithRotation } from "../math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -474,7 +477,15 @@ export class App extends React.Component<any, AppState> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const { atLeastOneVisibleElement, scrollBars } = renderScene(
|
const { atLeastOneVisibleElement, scrollBars } = renderScene(
|
||||||
globalSceneState.getAllElements(),
|
globalSceneState.getAllElements().filter((element) => {
|
||||||
|
// don't render text element that's being currently edited (it's
|
||||||
|
// rendered on remote only)
|
||||||
|
return (
|
||||||
|
!this.state.editingElement ||
|
||||||
|
this.state.editingElement.type !== "text" ||
|
||||||
|
element.id !== this.state.editingElement.id
|
||||||
|
);
|
||||||
|
}),
|
||||||
this.state,
|
this.state,
|
||||||
this.state.selectionElement,
|
this.state.selectionElement,
|
||||||
window.devicePixelRatio,
|
window.devicePixelRatio,
|
||||||
@ -717,9 +728,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
decryptedData: SocketUpdateDataSource["SCENE_INIT" | "SCENE_UPDATE"],
|
decryptedData: SocketUpdateDataSource["SCENE_INIT" | "SCENE_UPDATE"],
|
||||||
) => {
|
) => {
|
||||||
const { elements: remoteElements } = decryptedData.payload;
|
const { elements: remoteElements } = decryptedData.payload;
|
||||||
const restoredState = restore(remoteElements || [], null, {
|
|
||||||
scrollToContent: true,
|
|
||||||
});
|
|
||||||
// Perform reconciliation - in collaboration, if we encounter
|
// Perform reconciliation - in collaboration, if we encounter
|
||||||
// elements with more staler versions than ours, ignore them
|
// elements with more staler versions than ours, ignore them
|
||||||
// and keep ours.
|
// and keep ours.
|
||||||
@ -727,7 +736,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
globalSceneState.getAllElements() == null ||
|
globalSceneState.getAllElements() == null ||
|
||||||
globalSceneState.getAllElements().length === 0
|
globalSceneState.getAllElements().length === 0
|
||||||
) {
|
) {
|
||||||
globalSceneState.replaceAllElements(restoredState.elements);
|
globalSceneState.replaceAllElements(remoteElements);
|
||||||
} else {
|
} else {
|
||||||
// create a map of ids so we don't have to iterate
|
// create a map of ids so we don't have to iterate
|
||||||
// over the array more than once.
|
// over the array more than once.
|
||||||
@ -736,7 +745,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Reconcile
|
// Reconcile
|
||||||
const newElements = restoredState.elements
|
const newElements = remoteElements
|
||||||
.reduce((elements, element) => {
|
.reduce((elements, element) => {
|
||||||
// if the remote element references one that's currently
|
// if the remote element references one that's currently
|
||||||
// edited on local, skip it (it'll be added in the next
|
// edited on local, skip it (it'll be added in the next
|
||||||
@ -779,7 +788,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
}, [] as Mutable<typeof restoredState.elements>)
|
}, [] as Mutable<typeof remoteElements>)
|
||||||
// add local elements that weren't deleted or on remote
|
// add local elements that weren't deleted or on remote
|
||||||
.concat(...Object.values(localElementMap));
|
.concat(...Object.values(localElementMap));
|
||||||
|
|
||||||
@ -1082,6 +1091,96 @@ export class App extends React.Component<any, AppState> {
|
|||||||
globalSceneState.replaceAllElements(elements);
|
globalSceneState.replaceAllElements(elements);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleTextWysiwyg(
|
||||||
|
element: ExcalidrawTextElement,
|
||||||
|
{
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
isExistingElement = false,
|
||||||
|
}: { x: number; y: number; isExistingElement?: boolean },
|
||||||
|
) {
|
||||||
|
const resetSelection = () => {
|
||||||
|
this.setState({
|
||||||
|
draggingElement: null,
|
||||||
|
editingElement: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// deselect all other elements when inserting text
|
||||||
|
this.setState({ selectedElementIds: {} });
|
||||||
|
|
||||||
|
const deleteElement = () => {
|
||||||
|
globalSceneState.replaceAllElements([
|
||||||
|
...globalSceneState.getAllElements().map((_element) => {
|
||||||
|
if (_element.id === element.id) {
|
||||||
|
return newElementWith(_element, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return _element;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateElement = (text: string) => {
|
||||||
|
globalSceneState.replaceAllElements([
|
||||||
|
...globalSceneState.getAllElements().map((_element) => {
|
||||||
|
if (_element.id === element.id) {
|
||||||
|
return newTextElement({
|
||||||
|
..._element,
|
||||||
|
x: element.x,
|
||||||
|
y: element.y,
|
||||||
|
text,
|
||||||
|
font: this.state.currentItemFont,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _element;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
textWysiwyg({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
initText: element.text,
|
||||||
|
strokeColor: element.strokeColor,
|
||||||
|
opacity: element.opacity,
|
||||||
|
font: element.font,
|
||||||
|
angle: element.angle,
|
||||||
|
zoom: this.state.zoom,
|
||||||
|
onChange: withBatchedUpdates((text) => {
|
||||||
|
if (text) {
|
||||||
|
updateElement(text);
|
||||||
|
} else {
|
||||||
|
deleteElement();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onSubmit: withBatchedUpdates((text) => {
|
||||||
|
updateElement(text);
|
||||||
|
this.setState((prevState) => ({
|
||||||
|
selectedElementIds: {
|
||||||
|
...prevState.selectedElementIds,
|
||||||
|
[element.id]: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (this.state.elementLocked) {
|
||||||
|
setCursorForShape(this.state.elementType);
|
||||||
|
}
|
||||||
|
history.resumeRecording();
|
||||||
|
resetSelection();
|
||||||
|
}),
|
||||||
|
onCancel: withBatchedUpdates(() => {
|
||||||
|
deleteElement();
|
||||||
|
if (isExistingElement) {
|
||||||
|
history.resumeRecording();
|
||||||
|
}
|
||||||
|
resetSelection();
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// do an initial update to re-initialize element position since we were
|
||||||
|
// modifying element's x/y for sake of editor (case: syncing to remote)
|
||||||
|
updateElement(element.text);
|
||||||
|
}
|
||||||
|
|
||||||
private startTextEditing = ({
|
private startTextEditing = ({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@ -1124,13 +1223,10 @@ export class App extends React.Component<any, AppState> {
|
|||||||
let textX = clientX || x;
|
let textX = clientX || x;
|
||||||
let textY = clientY || y;
|
let textY = clientY || y;
|
||||||
|
|
||||||
if (elementAtPosition && isTextElement(elementAtPosition)) {
|
let isExistingTextElement = false;
|
||||||
globalSceneState.replaceAllElements(
|
|
||||||
globalSceneState
|
|
||||||
.getAllElements()
|
|
||||||
.filter((element) => element.id !== elementAtPosition.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (elementAtPosition && isTextElement(elementAtPosition)) {
|
||||||
|
isExistingTextElement = true;
|
||||||
const centerElementX = elementAtPosition.x + elementAtPosition.width / 2;
|
const centerElementX = elementAtPosition.x + elementAtPosition.width / 2;
|
||||||
const centerElementY = elementAtPosition.y + elementAtPosition.height / 2;
|
const centerElementY = elementAtPosition.y + elementAtPosition.height / 2;
|
||||||
|
|
||||||
@ -1152,7 +1248,13 @@ export class App extends React.Component<any, AppState> {
|
|||||||
x: centerElementX,
|
x: centerElementX,
|
||||||
y: centerElementY,
|
y: centerElementY,
|
||||||
});
|
});
|
||||||
} else if (centerIfPossible) {
|
} else {
|
||||||
|
globalSceneState.replaceAllElements([
|
||||||
|
...globalSceneState.getAllElements(),
|
||||||
|
element,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (centerIfPossible) {
|
||||||
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@ -1170,46 +1272,16 @@ export class App extends React.Component<any, AppState> {
|
|||||||
textY = snappedToCenterPosition.wysiwygY;
|
textY = snappedToCenterPosition.wysiwygY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetSelection = () => {
|
|
||||||
this.setState({
|
this.setState({
|
||||||
draggingElement: null,
|
editingElement: element,
|
||||||
editingElement: null,
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// deselect all other elements when inserting text
|
this.handleTextWysiwyg(element, {
|
||||||
this.setState({ selectedElementIds: {} });
|
|
||||||
|
|
||||||
textWysiwyg({
|
|
||||||
initText: element.text,
|
|
||||||
x: textX,
|
x: textX,
|
||||||
y: textY,
|
y: textY,
|
||||||
strokeColor: element.strokeColor,
|
isExistingElement: isExistingTextElement,
|
||||||
font: element.font,
|
|
||||||
opacity: this.state.currentItemOpacity,
|
|
||||||
zoom: this.state.zoom,
|
|
||||||
angle: element.angle,
|
|
||||||
onSubmit: (text) => {
|
|
||||||
if (text) {
|
|
||||||
globalSceneState.replaceAllElements([
|
|
||||||
...globalSceneState.getAllElements(),
|
|
||||||
// we need to recreate the element to update dimensions & position
|
|
||||||
newTextElement({ ...element, text, font: element.font }),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
this.setState((prevState) => ({
|
|
||||||
selectedElementIds: {
|
|
||||||
...prevState.selectedElementIds,
|
|
||||||
[element.id]: true,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
history.resumeRecording();
|
|
||||||
resetSelection();
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
resetSelection();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1670,48 +1742,14 @@ export class App extends React.Component<any, AppState> {
|
|||||||
font: this.state.currentItemFont,
|
font: this.state.currentItemFont,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetSelection = () => {
|
|
||||||
this.setState({
|
|
||||||
draggingElement: null,
|
|
||||||
editingElement: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
textWysiwyg({
|
|
||||||
initText: "",
|
|
||||||
x: snappedToCenterPosition?.wysiwygX ?? event.clientX,
|
|
||||||
y: snappedToCenterPosition?.wysiwygY ?? event.clientY,
|
|
||||||
strokeColor: this.state.currentItemStrokeColor,
|
|
||||||
opacity: this.state.currentItemOpacity,
|
|
||||||
font: this.state.currentItemFont,
|
|
||||||
zoom: this.state.zoom,
|
|
||||||
angle: 0,
|
|
||||||
onSubmit: (text) => {
|
|
||||||
if (text) {
|
|
||||||
globalSceneState.replaceAllElements([
|
globalSceneState.replaceAllElements([
|
||||||
...globalSceneState.getAllElements(),
|
...globalSceneState.getAllElements(),
|
||||||
newTextElement({
|
element,
|
||||||
...element,
|
|
||||||
text,
|
|
||||||
font: this.state.currentItemFont,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
this.setState((prevState) => ({
|
this.handleTextWysiwyg(element, {
|
||||||
selectedElementIds: {
|
x: snappedToCenterPosition?.wysiwygX ?? event.clientX,
|
||||||
...prevState.selectedElementIds,
|
y: snappedToCenterPosition?.wysiwygY ?? event.clientY,
|
||||||
[element.id]: true,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
if (this.state.elementLocked) {
|
|
||||||
setCursorForShape(this.state.elementType);
|
|
||||||
}
|
|
||||||
history.resumeRecording();
|
|
||||||
resetSelection();
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
resetSelection();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
resetCursor();
|
resetCursor();
|
||||||
if (!this.state.elementLocked) {
|
if (!this.state.elementLocked) {
|
||||||
|
@ -37,7 +37,7 @@ export function getSyncableElements(elements: readonly ExcalidrawElement[]) {
|
|||||||
// There are places in Excalidraw where synthetic invisibly small elements are added and removed.
|
// There are places in Excalidraw where synthetic invisibly small elements are added and removed.
|
||||||
// It's probably best to keep those local otherwise there might be a race condition that
|
// It's probably best to keep those local otherwise there might be a race condition that
|
||||||
// gets the app into an invalid state. I've never seen it happen but I'm worried about it :)
|
// gets the app into an invalid state. I've never seen it happen but I'm worried about it :)
|
||||||
return elements.filter((el) => !isInvisiblySmallElement(el));
|
return elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getElementMap(elements: readonly ExcalidrawElement[]) {
|
export function getElementMap(elements: readonly ExcalidrawElement[]) {
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { measureText } from "../utils";
|
import { measureText } from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
|
import { newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
type ElementConstructorOpts = {
|
type ElementConstructorOpts = {
|
||||||
x: ExcalidrawGenericElement["x"];
|
x: ExcalidrawGenericElement["x"];
|
||||||
@ -75,8 +76,10 @@ export function newTextElement(
|
|||||||
): ExcalidrawTextElement {
|
): ExcalidrawTextElement {
|
||||||
const { text, font } = opts;
|
const { text, font } = opts;
|
||||||
const metrics = measureText(text, font);
|
const metrics = measureText(text, font);
|
||||||
const textElement = {
|
const textElement = newElementWith(
|
||||||
|
{
|
||||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||||
|
isDeleted: false,
|
||||||
text: text,
|
text: text,
|
||||||
font: font,
|
font: font,
|
||||||
// Center the text
|
// Center the text
|
||||||
@ -85,7 +88,9 @@ export function newTextElement(
|
|||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
baseline: metrics.baseline,
|
baseline: metrics.baseline,
|
||||||
};
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
return textElement;
|
return textElement;
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ type TextWysiwygParams = {
|
|||||||
opacity: number;
|
opacity: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
|
onChange?: (text: string) => void;
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
@ -34,6 +35,7 @@ export function textWysiwyg({
|
|||||||
opacity,
|
opacity,
|
||||||
zoom,
|
zoom,
|
||||||
angle,
|
angle,
|
||||||
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: TextWysiwygParams) {
|
}: TextWysiwygParams) {
|
||||||
@ -96,6 +98,12 @@ export function textWysiwyg({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
editable.oninput = () => {
|
||||||
|
onChange(trimText(editable.innerText));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
editable.onkeydown = (ev) => {
|
editable.onkeydown = (ev) => {
|
||||||
if (ev.key === KEYS.ESCAPE) {
|
if (ev.key === KEYS.ESCAPE) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@ -121,9 +129,6 @@ export function textWysiwyg({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
editable.onblur = null;
|
|
||||||
editable.onkeydown = null;
|
|
||||||
editable.onpaste = null;
|
|
||||||
window.removeEventListener("wheel", stopEvent, true);
|
window.removeEventListener("wheel", stopEvent, true);
|
||||||
document.body.removeChild(editable);
|
document.body.removeChild(editable);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user