sync intermediate text updates (#1174)

* sync intermediate text updates

* fix initial render text position

* batch updates

* tweak onChange subscription
This commit is contained in:
David Luzar 2020-04-03 14:16:14 +02:00 committed by GitHub
parent 0c9459e9e5
commit 4912a29e75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 168 additions and 120 deletions

View File

@ -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,64 +1248,40 @@ export class App extends React.Component<any, AppState> {
x: centerElementX, x: centerElementX,
y: centerElementY, y: centerElementY,
}); });
} else if (centerIfPossible) { } else {
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition( globalSceneState.replaceAllElements([
x, ...globalSceneState.getAllElements(),
y, element,
this.state, ]);
this.canvas,
window.devicePixelRatio,
);
if (snappedToCenterPosition) { if (centerIfPossible) {
mutateElement(element, { const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
x: snappedToCenterPosition.elementCenterX, x,
y: snappedToCenterPosition.elementCenterY, y,
}); this.state,
textX = snappedToCenterPosition.wysiwygX; this.canvas,
textY = snappedToCenterPosition.wysiwygY; window.devicePixelRatio,
);
if (snappedToCenterPosition) {
mutateElement(element, {
x: snappedToCenterPosition.elementCenterX,
y: snappedToCenterPosition.elementCenterY,
});
textX = snappedToCenterPosition.wysiwygX;
textY = snappedToCenterPosition.wysiwygY;
}
} }
} }
const resetSelection = () => { this.setState({
this.setState({ editingElement: element,
draggingElement: null, });
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 = () => { globalSceneState.replaceAllElements([
this.setState({ ...globalSceneState.getAllElements(),
draggingElement: null, element,
editingElement: null, ]);
});
};
textWysiwyg({ this.handleTextWysiwyg(element, {
initText: "",
x: snappedToCenterPosition?.wysiwygX ?? event.clientX, x: snappedToCenterPosition?.wysiwygX ?? event.clientX,
y: snappedToCenterPosition?.wysiwygY ?? event.clientY, 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.getAllElements(),
newTextElement({
...element,
text,
font: this.state.currentItemFont,
}),
]);
}
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[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) {

View File

@ -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[]) {

View File

@ -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,17 +76,21 @@ 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), {
text: text, ..._newElementBase<ExcalidrawTextElement>("text", opts),
font: font, isDeleted: false,
// Center the text text: text,
x: opts.x - metrics.width / 2, font: font,
y: opts.y - metrics.height / 2, // Center the text
width: metrics.width, x: opts.x - metrics.width / 2,
height: metrics.height, y: opts.y - metrics.height / 2,
baseline: metrics.baseline, width: metrics.width,
}; height: metrics.height,
baseline: metrics.baseline,
},
{},
);
return textElement; return textElement;
} }

View File

@ -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);
} }