do not center text when not applicable (#1783)
This commit is contained in:
parent
9c89504b6f
commit
cd87bd6901
@ -20,7 +20,7 @@ import { AppState } from "../../src/types";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../appState";
|
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
@ -4,13 +4,13 @@ import {
|
|||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../appState";
|
} from "../constants";
|
||||||
import { register } from "./register";
|
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
|
||||||
|
|
||||||
let copiedStyles: string = "{}";
|
let copiedStyles: string = "{}";
|
||||||
|
|
||||||
|
@ -2,11 +2,11 @@ import oc from "open-color";
|
|||||||
import { AppState, FlooredNumber } from "./types";
|
import { AppState, FlooredNumber } from "./types";
|
||||||
import { getDateTime } from "./utils";
|
import { getDateTime } from "./utils";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { FontFamily } from "./element/types";
|
import {
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
export const DEFAULT_FONT_SIZE = 20;
|
DEFAULT_FONT_FAMILY,
|
||||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
DEFAULT_TEXT_ALIGN,
|
||||||
export const DEFAULT_TEXT_ALIGN = "left";
|
} from "./constants";
|
||||||
|
|
||||||
export const getDefaultAppState = (): AppState => {
|
export const getDefaultAppState = (): AppState => {
|
||||||
return {
|
return {
|
||||||
|
@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./element/types";
|
|||||||
import { newElement, newTextElement } from "./element";
|
import { newElement, newTextElement } from "./element";
|
||||||
import { AppState } from "./types";
|
import { AppState } from "./types";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
|
import { DEFAULT_VERTICAL_ALIGN } from "./constants";
|
||||||
|
|
||||||
interface Spreadsheet {
|
interface Spreadsheet {
|
||||||
yAxisLabel: string | null;
|
yAxisLabel: string | null;
|
||||||
@ -167,6 +168,7 @@ export function renderSpreadsheet(
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontFamily: appState.currentItemFontFamily,
|
fontFamily: appState.currentItemFontFamily,
|
||||||
textAlign: appState.currentItemTextAlign,
|
textAlign: appState.currentItemTextAlign,
|
||||||
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxYLabel = newTextElement({
|
const maxYLabel = newTextElement({
|
||||||
@ -183,6 +185,7 @@ export function renderSpreadsheet(
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontFamily: appState.currentItemFontFamily,
|
fontFamily: appState.currentItemFontFamily,
|
||||||
textAlign: appState.currentItemTextAlign,
|
textAlign: appState.currentItemTextAlign,
|
||||||
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bars = spreadsheet.values.map((value, i) => {
|
const bars = spreadsheet.values.map((value, i) => {
|
||||||
@ -226,6 +229,7 @@ export function renderSpreadsheet(
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontFamily: appState.currentItemFontFamily,
|
fontFamily: appState.currentItemFontFamily,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
width: BAR_WIDTH,
|
width: BAR_WIDTH,
|
||||||
angle: ANGLE,
|
angle: ANGLE,
|
||||||
});
|
});
|
||||||
@ -246,6 +250,7 @@ export function renderSpreadsheet(
|
|||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontFamily: appState.currentItemFontFamily,
|
fontFamily: appState.currentItemFontFamily,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
width: BAR_WIDTH,
|
width: BAR_WIDTH,
|
||||||
angle: ANGLE,
|
angle: ANGLE,
|
||||||
})
|
})
|
||||||
|
@ -27,6 +27,7 @@ import {
|
|||||||
getResizeArrowDirection,
|
getResizeArrowDirection,
|
||||||
getResizeHandlerFromCoords,
|
getResizeHandlerFromCoords,
|
||||||
isNonDeletedElement,
|
isNonDeletedElement,
|
||||||
|
updateTextElement,
|
||||||
dragSelectedElements,
|
dragSelectedElements,
|
||||||
getDragOffsetXY,
|
getDragOffsetXY,
|
||||||
dragNewElement,
|
dragNewElement,
|
||||||
@ -55,7 +56,11 @@ import Portal from "./Portal";
|
|||||||
|
|
||||||
import { renderScene } from "../renderer";
|
import { renderScene } from "../renderer";
|
||||||
import { AppState, GestureEvent, Gesture } from "../types";
|
import { AppState, GestureEvent, Gesture } from "../types";
|
||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
NonDeleted,
|
||||||
|
} from "../element/types";
|
||||||
|
|
||||||
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
||||||
|
|
||||||
@ -113,6 +118,7 @@ import {
|
|||||||
EVENT,
|
EVENT,
|
||||||
ENV,
|
ENV,
|
||||||
CANVAS_ONLY_ACTIONS,
|
CANVAS_ONLY_ACTIONS,
|
||||||
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
GRID_SIZE,
|
GRID_SIZE,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import {
|
import {
|
||||||
@ -583,7 +589,11 @@ class App extends React.Component<any, AppState> {
|
|||||||
if (scrollBars) {
|
if (scrollBars) {
|
||||||
currentScrollBars = scrollBars;
|
currentScrollBars = scrollBars;
|
||||||
}
|
}
|
||||||
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
|
const scrolledOutside =
|
||||||
|
// hide when editing text
|
||||||
|
this.state.editingElement?.type === "text"
|
||||||
|
? false
|
||||||
|
: !atLeastOneVisibleElement && elements.length > 0;
|
||||||
if (this.state.scrolledOutside !== scrolledOutside) {
|
if (this.state.scrolledOutside !== scrolledOutside) {
|
||||||
this.setState({ scrolledOutside: scrolledOutside });
|
this.setState({ scrolledOutside: scrolledOutside });
|
||||||
}
|
}
|
||||||
@ -790,6 +800,7 @@ class App extends React.Component<any, AppState> {
|
|||||||
fontSize: this.state.currentItemFontSize,
|
fontSize: this.state.currentItemFontSize,
|
||||||
fontFamily: this.state.currentItemFontFamily,
|
fontFamily: this.state.currentItemFontFamily,
|
||||||
textAlign: this.state.currentItemTextAlign,
|
textAlign: this.state.currentItemTextAlign,
|
||||||
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
});
|
});
|
||||||
|
|
||||||
globalSceneState.replaceAllElements([
|
globalSceneState.replaceAllElements([
|
||||||
@ -1250,12 +1261,9 @@ class App extends React.Component<any, AppState> {
|
|||||||
!isLinearElement(selectedElements[0])
|
!isLinearElement(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
const selectedElement = selectedElements[0];
|
const selectedElement = selectedElements[0];
|
||||||
const x = selectedElement.x + selectedElement.width / 2;
|
|
||||||
const y = selectedElement.y + selectedElement.height / 2;
|
|
||||||
|
|
||||||
this.startTextEditing({
|
this.startTextEditing({
|
||||||
x: x,
|
sceneX: selectedElement.x + selectedElement.width / 2,
|
||||||
y: y,
|
sceneY: selectedElement.y + selectedElement.height / 2,
|
||||||
});
|
});
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@ -1346,10 +1354,10 @@ class App extends React.Component<any, AppState> {
|
|||||||
private handleTextWysiwyg(
|
private handleTextWysiwyg(
|
||||||
element: ExcalidrawTextElement,
|
element: ExcalidrawTextElement,
|
||||||
{
|
{
|
||||||
x,
|
|
||||||
y,
|
|
||||||
isExistingElement = false,
|
isExistingElement = false,
|
||||||
}: { x: number; y: number; isExistingElement?: boolean },
|
}: {
|
||||||
|
isExistingElement?: boolean;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const resetSelection = () => {
|
const resetSelection = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -1358,26 +1366,13 @@ class App extends React.Component<any, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteElement = () => {
|
|
||||||
globalSceneState.replaceAllElements([
|
|
||||||
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
|
|
||||||
if (_element.id === element.id) {
|
|
||||||
return newElementWith(_element, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return _element;
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateElement = (text: string) => {
|
const updateElement = (text: string) => {
|
||||||
globalSceneState.replaceAllElements([
|
globalSceneState.replaceAllElements([
|
||||||
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
|
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
|
||||||
if (_element.id === element.id) {
|
if (_element.id === element.id && isTextElement(_element)) {
|
||||||
return newTextElement({
|
return updateTextElement(_element, {
|
||||||
...(_element as ExcalidrawTextElement),
|
|
||||||
x: element.x,
|
|
||||||
y: element.y,
|
|
||||||
text,
|
text,
|
||||||
|
isDeleted: !text.trim(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return _element;
|
return _element;
|
||||||
@ -1387,22 +1382,18 @@ class App extends React.Component<any, AppState> {
|
|||||||
|
|
||||||
textWysiwyg({
|
textWysiwyg({
|
||||||
id: element.id,
|
id: element.id,
|
||||||
x,
|
|
||||||
y,
|
|
||||||
initText: element.text,
|
|
||||||
strokeColor: element.strokeColor,
|
|
||||||
opacity: element.opacity,
|
|
||||||
fontSize: element.fontSize,
|
|
||||||
fontFamily: element.fontFamily,
|
|
||||||
angle: element.angle,
|
|
||||||
textAlign: element.textAlign,
|
|
||||||
zoom: this.state.zoom,
|
zoom: this.state.zoom,
|
||||||
|
getViewportCoords: (x, y) => {
|
||||||
|
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||||
|
{ sceneX: x, sceneY: y },
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
window.devicePixelRatio,
|
||||||
|
);
|
||||||
|
return [viewportX, viewportY];
|
||||||
|
},
|
||||||
onChange: withBatchedUpdates((text) => {
|
onChange: withBatchedUpdates((text) => {
|
||||||
if (text) {
|
updateElement(text);
|
||||||
updateElement(text);
|
|
||||||
} else {
|
|
||||||
deleteElement();
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
onSubmit: withBatchedUpdates((text) => {
|
onSubmit: withBatchedUpdates((text) => {
|
||||||
updateElement(text);
|
updateElement(text);
|
||||||
@ -1419,7 +1410,7 @@ class App extends React.Component<any, AppState> {
|
|||||||
resetSelection();
|
resetSelection();
|
||||||
}),
|
}),
|
||||||
onCancel: withBatchedUpdates(() => {
|
onCancel: withBatchedUpdates(() => {
|
||||||
deleteElement();
|
updateElement("");
|
||||||
if (isExistingElement) {
|
if (isExistingElement) {
|
||||||
history.resumeRecording();
|
history.resumeRecording();
|
||||||
}
|
}
|
||||||
@ -1438,20 +1429,11 @@ class App extends React.Component<any, AppState> {
|
|||||||
updateElement(element.text);
|
updateElement(element.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startTextEditing = ({
|
private getTextElementAtPosition(
|
||||||
x,
|
x: number,
|
||||||
y,
|
y: number,
|
||||||
clientX,
|
): NonDeleted<ExcalidrawTextElement> | null {
|
||||||
clientY,
|
const element = getElementAtPosition(
|
||||||
centerIfPossible = true,
|
|
||||||
}: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
clientX?: number;
|
|
||||||
clientY?: number;
|
|
||||||
centerIfPossible?: boolean;
|
|
||||||
}) => {
|
|
||||||
const elementAtPosition = getElementAtPosition(
|
|
||||||
globalSceneState.getElements(),
|
globalSceneState.getElements(),
|
||||||
this.state,
|
this.state,
|
||||||
x,
|
x,
|
||||||
@ -1459,78 +1441,83 @@ class App extends React.Component<any, AppState> {
|
|||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
const element =
|
if (element && isTextElement(element) && !element.isDeleted) {
|
||||||
elementAtPosition && isTextElement(elementAtPosition)
|
return element;
|
||||||
? elementAtPosition
|
}
|
||||||
: newTextElement({
|
return null;
|
||||||
x: x,
|
}
|
||||||
y: y,
|
|
||||||
strokeColor: this.state.currentItemStrokeColor,
|
|
||||||
backgroundColor: this.state.currentItemBackgroundColor,
|
|
||||||
fillStyle: this.state.currentItemFillStyle,
|
|
||||||
strokeWidth: this.state.currentItemStrokeWidth,
|
|
||||||
strokeStyle: this.state.currentItemStrokeStyle,
|
|
||||||
roughness: this.state.currentItemRoughness,
|
|
||||||
opacity: this.state.currentItemOpacity,
|
|
||||||
text: "",
|
|
||||||
fontSize: this.state.currentItemFontSize,
|
|
||||||
fontFamily: this.state.currentItemFontFamily,
|
|
||||||
textAlign: this.state.currentItemTextAlign,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({ editingElement: element });
|
private startTextEditing = ({
|
||||||
|
sceneX,
|
||||||
|
sceneY,
|
||||||
|
insertAtParentCenter = true,
|
||||||
|
}: {
|
||||||
|
/** X position to insert text at */
|
||||||
|
sceneX: number;
|
||||||
|
/** Y position to insert text at */
|
||||||
|
sceneY: number;
|
||||||
|
/** whether to attempt to insert at element center if applicable */
|
||||||
|
insertAtParentCenter?: boolean;
|
||||||
|
}) => {
|
||||||
|
const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
||||||
|
|
||||||
let textX = clientX || x;
|
const parentCenterPosition =
|
||||||
let textY = clientY || y;
|
insertAtParentCenter &&
|
||||||
|
this.getTextWysiwygSnappedToCenterPosition(
|
||||||
let isExistingTextElement = false;
|
sceneX,
|
||||||
|
sceneY,
|
||||||
if (elementAtPosition && isTextElement(elementAtPosition)) {
|
|
||||||
isExistingTextElement = true;
|
|
||||||
const centerElementX = elementAtPosition.x + elementAtPosition.width / 2;
|
|
||||||
const centerElementY = elementAtPosition.y + elementAtPosition.height / 2;
|
|
||||||
|
|
||||||
const {
|
|
||||||
x: centerElementXInViewport,
|
|
||||||
y: centerElementYInViewport,
|
|
||||||
} = sceneCoordsToViewportCoords(
|
|
||||||
{ sceneX: centerElementX, sceneY: centerElementY },
|
|
||||||
this.state,
|
this.state,
|
||||||
this.canvas,
|
this.canvas,
|
||||||
window.devicePixelRatio,
|
window.devicePixelRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
textX = centerElementXInViewport;
|
const element = existingTextElement
|
||||||
textY = centerElementYInViewport;
|
? existingTextElement
|
||||||
|
: newTextElement({
|
||||||
|
x: parentCenterPosition
|
||||||
|
? parentCenterPosition.elementCenterX
|
||||||
|
: sceneX,
|
||||||
|
y: parentCenterPosition
|
||||||
|
? parentCenterPosition.elementCenterY
|
||||||
|
: sceneY,
|
||||||
|
strokeColor: this.state.currentItemStrokeColor,
|
||||||
|
backgroundColor: this.state.currentItemBackgroundColor,
|
||||||
|
fillStyle: this.state.currentItemFillStyle,
|
||||||
|
strokeWidth: this.state.currentItemStrokeWidth,
|
||||||
|
strokeStyle: this.state.currentItemStrokeStyle,
|
||||||
|
roughness: this.state.currentItemRoughness,
|
||||||
|
opacity: this.state.currentItemOpacity,
|
||||||
|
text: "",
|
||||||
|
fontSize: this.state.currentItemFontSize,
|
||||||
|
fontFamily: this.state.currentItemFontFamily,
|
||||||
|
textAlign: parentCenterPosition
|
||||||
|
? "center"
|
||||||
|
: this.state.currentItemTextAlign,
|
||||||
|
verticalAlign: parentCenterPosition
|
||||||
|
? "middle"
|
||||||
|
: DEFAULT_VERTICAL_ALIGN,
|
||||||
|
});
|
||||||
|
|
||||||
// x and y will change after calling newTextElement function
|
this.setState({ editingElement: element });
|
||||||
mutateElement(element, {
|
|
||||||
x: centerElementX,
|
if (existingTextElement) {
|
||||||
y: centerElementY,
|
// if text element is no longer centered to a container, reset
|
||||||
});
|
// verticalAlign to default because it's currently internal-only
|
||||||
|
if (!parentCenterPosition || element.textAlign !== "center") {
|
||||||
|
mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
globalSceneState.replaceAllElements([
|
globalSceneState.replaceAllElements([
|
||||||
...globalSceneState.getElementsIncludingDeleted(),
|
...globalSceneState.getElementsIncludingDeleted(),
|
||||||
element,
|
element,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (centerIfPossible) {
|
// case: creating new text not centered to parent elemenent → offset Y
|
||||||
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
// so that the text is centered to cursor position
|
||||||
x,
|
if (!parentCenterPosition) {
|
||||||
y,
|
mutateElement(element, {
|
||||||
this.state,
|
y: element.y - element.baseline / 2,
|
||||||
this.canvas,
|
});
|
||||||
window.devicePixelRatio,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snappedToCenterPosition) {
|
|
||||||
mutateElement(element, {
|
|
||||||
x: snappedToCenterPosition.elementCenterX,
|
|
||||||
y: snappedToCenterPosition.elementCenterY,
|
|
||||||
});
|
|
||||||
textX = snappedToCenterPosition.wysiwygX;
|
|
||||||
textY = snappedToCenterPosition.wysiwygY;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1539,9 +1526,7 @@ class App extends React.Component<any, AppState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.handleTextWysiwyg(element, {
|
this.handleTextWysiwyg(element, {
|
||||||
x: textX,
|
isExistingElement: !!existingTextElement,
|
||||||
y: textY,
|
|
||||||
isExistingElement: isExistingTextElement,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1574,7 +1559,7 @@ class App extends React.Component<any, AppState> {
|
|||||||
|
|
||||||
resetCursor();
|
resetCursor();
|
||||||
|
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
event,
|
event,
|
||||||
this.state,
|
this.state,
|
||||||
this.canvas,
|
this.canvas,
|
||||||
@ -1588,8 +1573,8 @@ class App extends React.Component<any, AppState> {
|
|||||||
const hitElement = getElementAtPosition(
|
const hitElement = getElementAtPosition(
|
||||||
elements,
|
elements,
|
||||||
this.state,
|
this.state,
|
||||||
x,
|
sceneX,
|
||||||
y,
|
sceneY,
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1616,11 +1601,9 @@ class App extends React.Component<any, AppState> {
|
|||||||
resetCursor();
|
resetCursor();
|
||||||
|
|
||||||
this.startTextEditing({
|
this.startTextEditing({
|
||||||
x: x,
|
sceneX,
|
||||||
y: y,
|
sceneY,
|
||||||
clientX: event.clientX,
|
insertAtParentCenter: !event.altKey,
|
||||||
clientY: event.clientY,
|
|
||||||
centerIfPossible: !event.altKey,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2213,19 +2196,10 @@ class App extends React.Component<any, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
|
||||||
event,
|
|
||||||
this.state,
|
|
||||||
this.canvas,
|
|
||||||
window.devicePixelRatio,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.startTextEditing({
|
this.startTextEditing({
|
||||||
x: x,
|
sceneX: x,
|
||||||
y: y,
|
sceneY: y,
|
||||||
clientX: event.clientX,
|
insertAtParentCenter: !event.altKey,
|
||||||
clientY: event.clientY,
|
|
||||||
centerIfPossible: !event.altKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
resetCursor();
|
resetCursor();
|
||||||
@ -2640,7 +2614,12 @@ class App extends React.Component<any, AppState> {
|
|||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingElement: multiElement ? this.state.editingElement : null,
|
// text elements are reset on finalize, and resetting on pointerup
|
||||||
|
// may cause issues with double taps
|
||||||
|
editingElement:
|
||||||
|
multiElement || isTextElement(this.state.editingElement)
|
||||||
|
? this.state.editingElement
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
||||||
@ -3006,7 +2985,9 @@ class App extends React.Component<any, AppState> {
|
|||||||
scale: number,
|
scale: number,
|
||||||
) {
|
) {
|
||||||
const elementClickedInside = getElementContainingPosition(
|
const elementClickedInside = getElementContainingPosition(
|
||||||
globalSceneState.getElementsIncludingDeleted(),
|
globalSceneState
|
||||||
|
.getElementsIncludingDeleted()
|
||||||
|
.filter((element) => !isTextElement(element)),
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
);
|
);
|
||||||
@ -3022,13 +3003,13 @@ class App extends React.Component<any, AppState> {
|
|||||||
const isSnappedToCenter =
|
const isSnappedToCenter =
|
||||||
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
|
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
|
||||||
if (isSnappedToCenter) {
|
if (isSnappedToCenter) {
|
||||||
const { x: wysiwygX, y: wysiwygY } = sceneCoordsToViewportCoords(
|
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||||
{ sceneX: elementCenterX, sceneY: elementCenterY },
|
{ sceneX: elementCenterX, sceneY: elementCenterY },
|
||||||
state,
|
state,
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
|
return { viewportX, viewportY, elementCenterX, elementCenterY };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { FontFamily } from "./element/types";
|
||||||
|
|
||||||
export const DRAGGING_THRESHOLD = 10; // 10px
|
export const DRAGGING_THRESHOLD = 10; // 10px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
@ -67,6 +69,11 @@ export const FONT_FAMILY = {
|
|||||||
3: "Cascadia",
|
3: "Cascadia",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_FONT_SIZE = 20;
|
||||||
|
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||||
|
export const DEFAULT_TEXT_ALIGN = "left";
|
||||||
|
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||||
|
|
||||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||||
|
|
||||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||||
|
@ -8,8 +8,12 @@ import { DataState } from "./types";
|
|||||||
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
|
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { DEFAULT_TEXT_ALIGN, DEFAULT_FONT_FAMILY } from "../appState";
|
import {
|
||||||
import { FONT_FAMILY } from "../constants";
|
FONT_FAMILY,
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
|
DEFAULT_TEXT_ALIGN,
|
||||||
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
|
} from "../constants";
|
||||||
|
|
||||||
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
|
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
|
||||||
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
|
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
|
||||||
@ -75,7 +79,8 @@ const migrateElement = (
|
|||||||
fontFamily,
|
fontFamily,
|
||||||
text: element.text ?? "",
|
text: element.text ?? "",
|
||||||
baseline: element.baseline,
|
baseline: element.baseline,
|
||||||
textAlign: element.textAlign ?? DEFAULT_TEXT_ALIGN,
|
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
|
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||||
});
|
});
|
||||||
case "draw":
|
case "draw":
|
||||||
case "line":
|
case "line":
|
||||||
|
@ -8,6 +8,7 @@ import { isInvisiblySmallElement } from "./sizeHelpers";
|
|||||||
export {
|
export {
|
||||||
newElement,
|
newElement,
|
||||||
newTextElement,
|
newTextElement,
|
||||||
|
updateTextElement,
|
||||||
newLinearElement,
|
newLinearElement,
|
||||||
duplicateElement,
|
duplicateElement,
|
||||||
} from "./newElement";
|
} from "./newElement";
|
||||||
|
@ -81,6 +81,7 @@ it("clones text element", () => {
|
|||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontFamily: 1,
|
fontFamily: 1,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
|
verticalAlign: "top",
|
||||||
});
|
});
|
||||||
|
|
||||||
const copy = duplicateElement(null, new Map(), element);
|
const copy = duplicateElement(null, new Map(), element);
|
||||||
|
@ -7,12 +7,16 @@ import {
|
|||||||
TextAlign,
|
TextAlign,
|
||||||
FontFamily,
|
FontFamily,
|
||||||
GroupId,
|
GroupId,
|
||||||
|
VerticalAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { measureText, getFontString } from "../utils";
|
import { measureText, getFontString } from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
import { getNewGroupIdsForDuplication } from "../groups";
|
import { getNewGroupIdsForDuplication } from "../groups";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { getElementAbsoluteCoords } from ".";
|
||||||
|
import { adjustXYWithRotation } from "../math";
|
||||||
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
|
|
||||||
type ElementConstructorOpts = MarkOptional<
|
type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
|
||||||
@ -72,15 +76,39 @@ export const newElement = (
|
|||||||
): NonDeleted<ExcalidrawGenericElement> =>
|
): NonDeleted<ExcalidrawGenericElement> =>
|
||||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||||
|
|
||||||
|
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||||
|
function getTextElementPositionOffsets(
|
||||||
|
opts: {
|
||||||
|
textAlign: ExcalidrawTextElement["textAlign"];
|
||||||
|
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
x:
|
||||||
|
opts.textAlign === "center"
|
||||||
|
? metrics.width / 2
|
||||||
|
: opts.textAlign === "right"
|
||||||
|
? metrics.width
|
||||||
|
: 0,
|
||||||
|
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const newTextElement = (
|
export const newTextElement = (
|
||||||
opts: {
|
opts: {
|
||||||
text: string;
|
text: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: FontFamily;
|
fontFamily: FontFamily;
|
||||||
textAlign: TextAlign;
|
textAlign: TextAlign;
|
||||||
|
verticalAlign: VerticalAlign;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawTextElement> => {
|
): NonDeleted<ExcalidrawTextElement> => {
|
||||||
const metrics = measureText(opts.text, getFontString(opts));
|
const metrics = measureText(opts.text, getFontString(opts));
|
||||||
|
const offsets = getTextElementPositionOffsets(opts, metrics);
|
||||||
const textElement = newElementWith(
|
const textElement = newElementWith(
|
||||||
{
|
{
|
||||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||||
@ -88,9 +116,9 @@ export const newTextElement = (
|
|||||||
fontSize: opts.fontSize,
|
fontSize: opts.fontSize,
|
||||||
fontFamily: opts.fontFamily,
|
fontFamily: opts.fontFamily,
|
||||||
textAlign: opts.textAlign,
|
textAlign: opts.textAlign,
|
||||||
// Center the text
|
verticalAlign: opts.verticalAlign,
|
||||||
x: opts.x - metrics.width / 2,
|
x: opts.x - offsets.x,
|
||||||
y: opts.y - metrics.height / 2,
|
y: opts.y - offsets.y,
|
||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
baseline: metrics.baseline,
|
baseline: metrics.baseline,
|
||||||
@ -101,6 +129,84 @@ export const newTextElement = (
|
|||||||
return textElement;
|
return textElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAdjustedDimensions = (
|
||||||
|
element: ExcalidrawTextElement,
|
||||||
|
nextText: string,
|
||||||
|
): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
baseline: number;
|
||||||
|
} => {
|
||||||
|
const {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
baseline: nextBaseline,
|
||||||
|
} = measureText(nextText, getFontString(element));
|
||||||
|
|
||||||
|
const { textAlign, verticalAlign } = element;
|
||||||
|
|
||||||
|
let x, y;
|
||||||
|
|
||||||
|
if (textAlign === "center" && verticalAlign === "middle") {
|
||||||
|
const prevMetrics = measureText(element.text, getFontString(element));
|
||||||
|
const offsets = getTextElementPositionOffsets(element, {
|
||||||
|
width: nextWidth - prevMetrics.width,
|
||||||
|
height: nextHeight - prevMetrics.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
x = element.x - offsets.x;
|
||||||
|
y = element.y - offsets.y;
|
||||||
|
} else {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
|
||||||
|
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
);
|
||||||
|
const deltaX1 = (x1 - nextX1) / 2;
|
||||||
|
const deltaY1 = (y1 - nextY1) / 2;
|
||||||
|
const deltaX2 = (x2 - nextX2) / 2;
|
||||||
|
const deltaY2 = (y2 - nextY2) / 2;
|
||||||
|
|
||||||
|
[x, y] = adjustXYWithRotation(
|
||||||
|
{
|
||||||
|
s: true,
|
||||||
|
e: textAlign === "center" || textAlign === "left",
|
||||||
|
w: textAlign === "center" || textAlign === "right",
|
||||||
|
},
|
||||||
|
element.x,
|
||||||
|
element.y,
|
||||||
|
element.angle,
|
||||||
|
deltaX1,
|
||||||
|
deltaY1,
|
||||||
|
deltaX2,
|
||||||
|
deltaY2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
x: Number.isFinite(x) ? x : element.x,
|
||||||
|
y: Number.isFinite(y) ? y : element.y,
|
||||||
|
baseline: nextBaseline,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTextElement = (
|
||||||
|
element: ExcalidrawTextElement,
|
||||||
|
{ text, isDeleted }: { text: string; isDeleted?: boolean },
|
||||||
|
): ExcalidrawTextElement => {
|
||||||
|
return newElementWith(element, {
|
||||||
|
text,
|
||||||
|
isDeleted: isDeleted ?? element.isDeleted,
|
||||||
|
...getAdjustedDimensions(element, text),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const newLinearElement = (
|
export const newLinearElement = (
|
||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawLinearElement["type"];
|
type: ExcalidrawLinearElement["type"];
|
||||||
|
@ -248,6 +248,26 @@ const measureFontSizeFromWH = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSidesForResizeHandle = (
|
||||||
|
resizeHandle: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
|
||||||
|
isResizeFromCenter: boolean,
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
n:
|
||||||
|
/^(n|ne|nw)$/.test(resizeHandle) ||
|
||||||
|
(isResizeFromCenter && /^(s|se|sw)$/.test(resizeHandle)),
|
||||||
|
s:
|
||||||
|
/^(s|se|sw)$/.test(resizeHandle) ||
|
||||||
|
(isResizeFromCenter && /^(n|ne|nw)$/.test(resizeHandle)),
|
||||||
|
w:
|
||||||
|
/^(w|nw|sw)$/.test(resizeHandle) ||
|
||||||
|
(isResizeFromCenter && /^(e|ne|se)$/.test(resizeHandle)),
|
||||||
|
e:
|
||||||
|
/^(e|ne|se)$/.test(resizeHandle) ||
|
||||||
|
(isResizeFromCenter && /^(w|nw|sw)$/.test(resizeHandle)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
const resizeSingleTextElement = (
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
resizeHandle: "nw" | "ne" | "sw" | "se",
|
resizeHandle: "nw" | "ne" | "sw" | "se",
|
||||||
@ -310,7 +330,7 @@ const resizeSingleTextElement = (
|
|||||||
const deltaX2 = (x2 - nextX2) / 2;
|
const deltaX2 = (x2 - nextX2) / 2;
|
||||||
const deltaY2 = (y2 - nextY2) / 2;
|
const deltaY2 = (y2 - nextY2) / 2;
|
||||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||||
resizeHandle,
|
getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
|
||||||
element.x,
|
element.x,
|
||||||
element.y,
|
element.y,
|
||||||
element.angle,
|
element.angle,
|
||||||
@ -318,7 +338,6 @@ const resizeSingleTextElement = (
|
|||||||
deltaY1,
|
deltaY1,
|
||||||
deltaX2,
|
deltaX2,
|
||||||
deltaY2,
|
deltaY2,
|
||||||
isResizeFromCenter,
|
|
||||||
);
|
);
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
fontSize: nextFont.size,
|
fontSize: nextFont.size,
|
||||||
@ -403,7 +422,7 @@ const resizeSingleElement = (
|
|||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||||
resizeHandle,
|
getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
|
||||||
element.x - flipDiffX,
|
element.x - flipDiffX,
|
||||||
element.y - flipDiffY,
|
element.y - flipDiffY,
|
||||||
element.angle,
|
element.angle,
|
||||||
@ -411,7 +430,6 @@ const resizeSingleElement = (
|
|||||||
deltaY1,
|
deltaY1,
|
||||||
deltaX2,
|
deltaX2,
|
||||||
deltaY2,
|
deltaY2,
|
||||||
isResizeFromCenter,
|
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
nextWidth !== 0 &&
|
nextWidth !== 0 &&
|
||||||
|
@ -1,116 +1,111 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { selectNode, isWritableElement, getFontString } from "../utils";
|
import { isWritableElement, getFontString } from "../utils";
|
||||||
import { globalSceneState } from "../scene";
|
import { globalSceneState } from "../scene";
|
||||||
import { isTextElement } from "./typeChecks";
|
import { isTextElement } from "./typeChecks";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { FontFamily } from "./types";
|
import { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
const trimText = (text: string) => {
|
const normalizeText = (text: string) => {
|
||||||
// whitespace only → trim all because we'd end up inserting invisible element
|
return (
|
||||||
if (!text.trim()) {
|
text
|
||||||
return "";
|
// replace tabs with spaces so they render and measure correctly
|
||||||
}
|
.replace(/\t/g, " ")
|
||||||
// replace leading/trailing newlines (only) otherwise it messes up bounding
|
// normalize newlines
|
||||||
// box calculation (there's also a bug in FF which inserts trailing newline
|
.replace(/\r?\n|\r/g, "\n")
|
||||||
// for multiline texts)
|
);
|
||||||
return text.replace(/^\n+|\n+$/g, "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TextWysiwygParams = {
|
const getTransform = (
|
||||||
id: string;
|
width: number,
|
||||||
initText: string;
|
height: number,
|
||||||
x: number;
|
angle: number,
|
||||||
y: number;
|
zoom: number,
|
||||||
strokeColor: string;
|
) => {
|
||||||
fontSize: number;
|
const degree = (180 * angle) / Math.PI;
|
||||||
fontFamily: FontFamily;
|
return `translate(${(width * (zoom - 1)) / 2}px, ${
|
||||||
opacity: number;
|
(height * (zoom - 1)) / 2
|
||||||
zoom: number;
|
}px) scale(${zoom}) rotate(${degree}deg)`;
|
||||||
angle: number;
|
|
||||||
textAlign: string;
|
|
||||||
onChange?: (text: string) => void;
|
|
||||||
onSubmit: (text: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const textWysiwyg = ({
|
export const textWysiwyg = ({
|
||||||
id,
|
id,
|
||||||
initText,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
strokeColor,
|
|
||||||
fontSize,
|
|
||||||
fontFamily,
|
|
||||||
opacity,
|
|
||||||
zoom,
|
zoom,
|
||||||
angle,
|
|
||||||
onChange,
|
onChange,
|
||||||
textAlign,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: TextWysiwygParams) => {
|
getViewportCoords,
|
||||||
const editable = document.createElement("div");
|
}: {
|
||||||
try {
|
id: ExcalidrawElement["id"];
|
||||||
editable.contentEditable = "plaintext-only";
|
zoom: number;
|
||||||
} catch {
|
onChange?: (text: string) => void;
|
||||||
editable.contentEditable = "true";
|
onSubmit: (text: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
getViewportCoords: (x: number, y: number) => [number, number];
|
||||||
|
}) => {
|
||||||
|
function updateWysiwygStyle() {
|
||||||
|
const updatedElement = globalSceneState.getElement(id);
|
||||||
|
if (isTextElement(updatedElement)) {
|
||||||
|
const [viewportX, viewportY] = getViewportCoords(
|
||||||
|
updatedElement.x,
|
||||||
|
updatedElement.y,
|
||||||
|
);
|
||||||
|
const { textAlign, angle } = updatedElement;
|
||||||
|
|
||||||
|
editable.value = updatedElement.text;
|
||||||
|
|
||||||
|
const lines = updatedElement.text.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
const lineHeight = updatedElement.height / lines.length;
|
||||||
|
|
||||||
|
Object.assign(editable.style, {
|
||||||
|
font: getFontString(updatedElement),
|
||||||
|
// must be defined *after* font ¯\_(ツ)_/¯
|
||||||
|
lineHeight: `${lineHeight}px`,
|
||||||
|
width: `${updatedElement.width}px`,
|
||||||
|
height: `${updatedElement.height}px`,
|
||||||
|
left: `${viewportX}px`,
|
||||||
|
top: `${viewportY}px`,
|
||||||
|
transform: getTransform(
|
||||||
|
updatedElement.width,
|
||||||
|
updatedElement.height,
|
||||||
|
angle,
|
||||||
|
zoom,
|
||||||
|
),
|
||||||
|
textAlign: textAlign,
|
||||||
|
color: updatedElement.strokeColor,
|
||||||
|
opacity: updatedElement.opacity / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editable = document.createElement("textarea");
|
||||||
|
|
||||||
editable.dir = "auto";
|
editable.dir = "auto";
|
||||||
editable.tabIndex = 0;
|
editable.tabIndex = 0;
|
||||||
editable.innerText = initText;
|
|
||||||
editable.dataset.type = "wysiwyg";
|
editable.dataset.type = "wysiwyg";
|
||||||
|
// prevent line wrapping on Safari
|
||||||
const degree = (180 * angle) / Math.PI;
|
editable.wrap = "off";
|
||||||
|
|
||||||
Object.assign(editable.style, {
|
Object.assign(editable.style, {
|
||||||
color: strokeColor,
|
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
opacity: opacity / 100,
|
|
||||||
top: `${y}px`,
|
|
||||||
left: `${x}px`,
|
|
||||||
transform: `translate(-50%, -50%) scale(${zoom}) rotate(${degree}deg)`,
|
|
||||||
textAlign: textAlign,
|
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
font: getFontString({ fontSize, fontFamily }),
|
|
||||||
padding: "4px",
|
|
||||||
// This needs to have "1px solid" otherwise the carret doesn't show up
|
|
||||||
// the first time on Safari and Chrome!
|
|
||||||
outline: "1px solid transparent",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
minHeight: "1em",
|
minHeight: "1em",
|
||||||
backfaceVisibility: "hidden",
|
backfaceVisibility: "hidden",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
border: 0,
|
||||||
|
outline: 0,
|
||||||
|
resize: "none",
|
||||||
|
background: "transparent",
|
||||||
|
overflow: "hidden",
|
||||||
|
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||||||
|
whiteSpace: "pre",
|
||||||
});
|
});
|
||||||
|
|
||||||
editable.onpaste = (event) => {
|
updateWysiwygStyle();
|
||||||
try {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection?.rangeCount) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selection.deleteFromDocument();
|
|
||||||
|
|
||||||
const text = event.clipboardData!.getData("text").replace(/\r\n?/g, "\n");
|
|
||||||
|
|
||||||
const span = document.createElement("span");
|
|
||||||
span.innerText = text;
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
range.insertNode(span);
|
|
||||||
|
|
||||||
// deselect
|
|
||||||
window.getSelection()!.removeAllRanges();
|
|
||||||
range.setStart(span, span.childNodes.length);
|
|
||||||
range.setEnd(span, span.childNodes.length);
|
|
||||||
selection.addRange(range);
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
editable.oninput = () => {
|
editable.oninput = () => {
|
||||||
onChange(trimText(editable.innerText));
|
onChange(normalizeText(editable.value));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,8 +129,8 @@ export const textWysiwyg = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (editable.innerText) {
|
if (editable.value) {
|
||||||
onSubmit(trimText(editable.innerText));
|
onSubmit(normalizeText(editable.value));
|
||||||
} else {
|
} else {
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
@ -149,10 +144,10 @@ export const textWysiwyg = ({
|
|||||||
isDestroyed = true;
|
isDestroyed = true;
|
||||||
// remove events to ensure they don't late-fire
|
// remove events to ensure they don't late-fire
|
||||||
editable.onblur = null;
|
editable.onblur = null;
|
||||||
editable.onpaste = null;
|
|
||||||
editable.oninput = null;
|
editable.oninput = null;
|
||||||
editable.onkeydown = null;
|
editable.onkeydown = null;
|
||||||
|
|
||||||
|
window.removeEventListener("resize", updateWysiwygStyle);
|
||||||
window.removeEventListener("wheel", stopEvent, true);
|
window.removeEventListener("wheel", stopEvent, true);
|
||||||
window.removeEventListener("pointerdown", onPointerDown);
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
window.removeEventListener("pointerup", rebindBlur);
|
window.removeEventListener("pointerup", rebindBlur);
|
||||||
@ -191,26 +186,19 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
// handle updates of textElement properties of editing element
|
// handle updates of textElement properties of editing element
|
||||||
const unbindUpdate = globalSceneState.addCallback(() => {
|
const unbindUpdate = globalSceneState.addCallback(() => {
|
||||||
const editingElement = globalSceneState
|
updateWysiwygStyle();
|
||||||
.getElementsIncludingDeleted()
|
|
||||||
.find((element) => element.id === id);
|
|
||||||
if (editingElement && isTextElement(editingElement)) {
|
|
||||||
Object.assign(editable.style, {
|
|
||||||
font: getFontString(editingElement),
|
|
||||||
textAlign: editingElement.textAlign,
|
|
||||||
color: editingElement.strokeColor,
|
|
||||||
opacity: editingElement.opacity / 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
editable.focus();
|
editable.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
let isDestroyed = false;
|
let isDestroyed = false;
|
||||||
|
|
||||||
editable.onblur = handleSubmit;
|
editable.onblur = handleSubmit;
|
||||||
|
// reposition wysiwyg in case of window resize. Happens on mobile when
|
||||||
|
// device keyboard is opened.
|
||||||
|
window.addEventListener("resize", updateWysiwygStyle);
|
||||||
window.addEventListener("pointerdown", onPointerDown);
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
window.addEventListener("wheel", stopEvent, true);
|
window.addEventListener("wheel", stopEvent, true);
|
||||||
document.body.appendChild(editable);
|
document.body.appendChild(editable);
|
||||||
editable.focus();
|
editable.focus();
|
||||||
selectNode(editable);
|
editable.select();
|
||||||
};
|
};
|
||||||
|
@ -60,6 +60,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
|||||||
text: string;
|
text: string;
|
||||||
baseline: number;
|
baseline: number;
|
||||||
textAlign: TextAlign;
|
textAlign: TextAlign;
|
||||||
|
verticalAlign: VerticalAlign;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||||
@ -72,6 +73,7 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
|||||||
export type PointerType = "mouse" | "pen" | "touch";
|
export type PointerType = "mouse" | "pen" | "touch";
|
||||||
|
|
||||||
export type TextAlign = "left" | "center" | "right";
|
export type TextAlign = "left" | "center" | "right";
|
||||||
|
export type VerticalAlign = "top" | "middle";
|
||||||
|
|
||||||
export type FontFamily = keyof typeof FONT_FAMILY;
|
export type FontFamily = keyof typeof FONT_FAMILY;
|
||||||
export type FontString = string & { _brand: "fontString" };
|
export type FontString = string & { _brand: "fontString" };
|
||||||
|
71
src/math.ts
71
src/math.ts
@ -57,7 +57,12 @@ export const rotate = (
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const adjustXYWithRotation = (
|
export const adjustXYWithRotation = (
|
||||||
side: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
|
sides: {
|
||||||
|
n?: boolean;
|
||||||
|
e?: boolean;
|
||||||
|
s?: boolean;
|
||||||
|
w?: boolean;
|
||||||
|
},
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
angle: number,
|
angle: number,
|
||||||
@ -65,49 +70,35 @@ export const adjustXYWithRotation = (
|
|||||||
deltaY1: number,
|
deltaY1: number,
|
||||||
deltaX2: number,
|
deltaX2: number,
|
||||||
deltaY2: number,
|
deltaY2: number,
|
||||||
isResizeFromCenter: boolean,
|
|
||||||
): [number, number] => {
|
): [number, number] => {
|
||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
if (side === "e" || side === "ne" || side === "se") {
|
if (sides.e && sides.w) {
|
||||||
if (isResizeFromCenter) {
|
x += deltaX1 + deltaX2;
|
||||||
x += deltaX1 + deltaX2;
|
} else if (sides.e) {
|
||||||
} else {
|
x += deltaX1 * (1 + cos);
|
||||||
x += deltaX1 * (1 + cos);
|
y += deltaX1 * sin;
|
||||||
y += deltaX1 * sin;
|
x += deltaX2 * (1 - cos);
|
||||||
x += deltaX2 * (1 - cos);
|
y += deltaX2 * -sin;
|
||||||
y += deltaX2 * -sin;
|
} else if (sides.w) {
|
||||||
}
|
x += deltaX1 * (1 - cos);
|
||||||
|
y += deltaX1 * -sin;
|
||||||
|
x += deltaX2 * (1 + cos);
|
||||||
|
y += deltaX2 * sin;
|
||||||
}
|
}
|
||||||
if (side === "s" || side === "sw" || side === "se") {
|
|
||||||
if (isResizeFromCenter) {
|
if (sides.n && sides.s) {
|
||||||
y += deltaY1 + deltaY2;
|
y += deltaY1 + deltaY2;
|
||||||
} else {
|
} else if (sides.n) {
|
||||||
x += deltaY1 * -sin;
|
x += deltaY1 * sin;
|
||||||
y += deltaY1 * (1 + cos);
|
y += deltaY1 * (1 - cos);
|
||||||
x += deltaY2 * sin;
|
x += deltaY2 * -sin;
|
||||||
y += deltaY2 * (1 - cos);
|
y += deltaY2 * (1 + cos);
|
||||||
}
|
} else if (sides.s) {
|
||||||
}
|
x += deltaY1 * -sin;
|
||||||
if (side === "w" || side === "nw" || side === "sw") {
|
y += deltaY1 * (1 + cos);
|
||||||
if (isResizeFromCenter) {
|
x += deltaY2 * sin;
|
||||||
x += deltaX1 + deltaX2;
|
y += deltaY2 * (1 - cos);
|
||||||
} else {
|
|
||||||
x += deltaX1 * (1 - cos);
|
|
||||||
y += deltaX1 * -sin;
|
|
||||||
x += deltaX2 * (1 + cos);
|
|
||||||
y += deltaX2 * sin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (side === "n" || side === "nw" || side === "ne") {
|
|
||||||
if (isResizeFromCenter) {
|
|
||||||
y += deltaY1 + deltaY2;
|
|
||||||
} else {
|
|
||||||
x += deltaY1 * sin;
|
|
||||||
y += deltaY1 * (1 - cos);
|
|
||||||
x += deltaY2 * -sin;
|
|
||||||
y += deltaY2 * (1 + cos);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [x, y];
|
return [x, y];
|
||||||
};
|
};
|
||||||
|
@ -4,11 +4,11 @@ import { newTextElement } from "../element";
|
|||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
||||||
import { distance, SVG_NS, measureText, getFontString } from "../utils";
|
import { distance, SVG_NS } from "../utils";
|
||||||
import { normalizeScroll } from "./scroll";
|
import { normalizeScroll } from "./scroll";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { DEFAULT_FONT_FAMILY } from "../appState";
|
import { DEFAULT_FONT_FAMILY, DEFAULT_VERTICAL_ALIGN } from "../constants";
|
||||||
|
|
||||||
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||||
|
|
||||||
@ -150,20 +150,13 @@ export const exportToSvg = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getWatermarkElement = (maxX: number, maxY: number) => {
|
const getWatermarkElement = (maxX: number, maxY: number) => {
|
||||||
const text = t("labels.madeWithExcalidraw");
|
|
||||||
const fontSize = 16;
|
|
||||||
const fontFamily = DEFAULT_FONT_FAMILY;
|
|
||||||
const { width: textWidth } = measureText(
|
|
||||||
text,
|
|
||||||
getFontString({ fontSize, fontFamily }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return newTextElement({
|
return newTextElement({
|
||||||
text,
|
text: t("labels.madeWithExcalidraw"),
|
||||||
fontSize,
|
fontSize: 16,
|
||||||
fontFamily,
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
textAlign: "center",
|
textAlign: "right",
|
||||||
x: maxX - textWidth / 2,
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||||
|
x: maxX,
|
||||||
y: maxY + 16,
|
y: maxY + 16,
|
||||||
strokeColor: oc.gray[5],
|
strokeColor: oc.gray[5],
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
17
src/utils.ts
17
src/utils.ts
@ -88,8 +88,12 @@ export const measureText = (text: string, font: FontString) => {
|
|||||||
line.style.whiteSpace = "pre";
|
line.style.whiteSpace = "pre";
|
||||||
line.style.font = font;
|
line.style.font = font;
|
||||||
body.appendChild(line);
|
body.appendChild(line);
|
||||||
// Now we can measure width and height of the letter
|
line.innerText = text
|
||||||
line.innerText = text;
|
.split("\n")
|
||||||
|
// replace empty lines with single space because leading/trailing empty
|
||||||
|
// lines would be stripped from computation
|
||||||
|
.map((x) => x || " ")
|
||||||
|
.join("\n");
|
||||||
const width = line.offsetWidth;
|
const width = line.offsetWidth;
|
||||||
const height = line.offsetHeight;
|
const height = line.offsetHeight;
|
||||||
// Now creating 1px sized item that will be aligned to baseline
|
// Now creating 1px sized item that will be aligned to baseline
|
||||||
@ -214,13 +218,8 @@ export const sceneCoordsToViewportCoords = (
|
|||||||
scale: number,
|
scale: number,
|
||||||
) => {
|
) => {
|
||||||
const zoomOrigin = getZoomOrigin(canvas, scale);
|
const zoomOrigin = getZoomOrigin(canvas, scale);
|
||||||
const sceneXWithZoomAndScroll =
|
const x = zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
|
||||||
zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
|
const y = zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
|
||||||
const sceneYWithZoomAndScroll =
|
|
||||||
zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
|
|
||||||
|
|
||||||
const x = sceneXWithZoomAndScroll;
|
|
||||||
const y = sceneYWithZoomAndScroll;
|
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user