feat: support decreasing/increasing fontSize via keyboard (#4553)

Co-authored-by: david <dw@dw.local>
This commit is contained in:
David Luzar 2022-01-12 15:21:36 +01:00 committed by GitHub
parent 4501d6d630
commit a51ed9ced6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 214 additions and 93 deletions

View File

@ -41,9 +41,16 @@ import {
isTextElement, isTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement"; import {
import { isLinearElement, isLinearElementType } from "../element/typeChecks"; getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
isBoundToContainer,
isLinearElement,
isLinearElementType,
} from "../element/typeChecks";
import { import {
Arrowhead, Arrowhead,
ExcalidrawElement, ExcalidrawElement,
@ -53,6 +60,7 @@ import {
TextAlign, TextAlign,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeSharpness, canChangeSharpness,
@ -63,10 +71,11 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import Scene from "../scene/Scene";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
const changeProperty = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
@ -108,6 +117,79 @@ const getFormValue = function <T>(
); );
}; };
const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement,
) => {
if (isBoundToContainer(nextElement)) {
return nextElement;
}
return mutateElement(
nextElement,
{
x:
prevElement.textAlign === "left"
? prevElement.x
: prevElement.x +
(prevElement.width - nextElement.width) /
(prevElement.textAlign === "center" ? 2 : 1),
// centering vertically is non-standard, but for Excalidraw I think
// it makes sense
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
},
false,
);
};
const changeFontSize = (
elements: readonly ExcalidrawElement[],
appState: AppState,
getNewFontSize: (element: ExcalidrawTextElement) => number,
) => {
const newFontSizes = new Set<number>();
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
// update state only if we've set all select text elements to
// the same font size
currentItemFontSize:
newFontSizes.size === 1
? [...newFontSizes][0]
: appState.currentItemFontSize,
},
commitToHistory: true,
};
};
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register({ export const actionChangeStrokeColor = register({
name: "changeStrokeColor", name: "changeStrokeColor",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
@ -438,33 +520,7 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({ export const actionChangeFontSize = register({
name: "changeFontSize", name: "changeFontSize",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return changeFontSize(elements, appState, () => value);
elements: changeProperty(
elements,
appState,
(el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
fontSize: value,
});
let container = null;
if (el.containerId) {
container = Scene.getScene(el)!.getElement(el.containerId);
}
redrawTextBoundingBox(element, container, appState);
return element;
}
return el;
},
true,
),
appState: {
...appState,
currentItemFontSize: value,
},
commitToHistory: true,
};
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
@ -514,6 +570,44 @@ export const actionChangeFontSize = register({
), ),
}); });
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
// get previous value before relative increase (doesn't work fully
// due to rounding and float precision issues)
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.COMMA needed for MacOS
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
);
},
});
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
);
},
keyTest: (event) => {
return (
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
// KEYS.PERIOD needed for MacOS
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
);
},
});
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
@ -521,20 +615,23 @@ export const actionChangeFontFamily = register({
elements: changeProperty( elements: changeProperty(
elements, elements,
appState, appState,
(el) => { (oldElement) => {
if (isTextElement(el)) { if (isTextElement(oldElement)) {
const element: ExcalidrawTextElement = newElementWith(el, { const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: value, fontFamily: value,
}); },
let container = null; );
if (el.containerId) { redrawTextBoundingBox(
container = Scene.getScene(el)!.getElement(el.containerId); newElement,
} getContainerElement(oldElement),
redrawTextBoundingBox(element, container, appState); appState,
return element; );
return newElement;
} }
return el; return oldElement;
}, },
true, true,
), ),
@ -603,20 +700,23 @@ export const actionChangeTextAlign = register({
elements: changeProperty( elements: changeProperty(
elements, elements,
appState, appState,
(el) => { (oldElement) => {
if (isTextElement(el)) { if (isTextElement(oldElement)) {
const element: ExcalidrawTextElement = newElementWith(el, { const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
textAlign: value, textAlign: value,
}); },
let container = null; );
if (el.containerId) { redrawTextBoundingBox(
container = Scene.getScene(el)!.getElement(el.containerId); newElement,
} getContainerElement(oldElement),
redrawTextBoundingBox(element, container, appState); appState,
return element; );
return newElement;
} }
return el; return oldElement;
}, },
true, true,
), ),

View File

@ -12,9 +12,7 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import Scene from "../scene/Scene"; import { getContainerElement } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks";
import { ExcalidrawTextElement } from "../element/types";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
@ -58,22 +56,16 @@ export const actionPasteStyles = register({
opacity: pastedElement?.opacity, opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness, roughness: pastedElement?.roughness,
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement) && isTextElement(element)) {
mutateElement(newElement, { mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE, fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY, fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN, textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
let container = null;
if (isBoundToContainer(element)) {
container = Scene.getScene(element)!.getElement(
element.containerId,
);
}
redrawTextBoundingBox( redrawTextBoundingBox(
element as ExcalidrawTextElement, element,
container, getContainerElement(element),
appState, appState,
); );
} }

View File

@ -101,7 +101,9 @@ export type ActionName =
| "flipVertical" | "flipVertical"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme"; | "toggleTheme"
| "increaseFontSize"
| "decreaseFontSize";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

View File

@ -1649,7 +1649,10 @@ class App extends React.Component<AppProps, AppState> {
} }
if ( if (
(isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || (isWritableElement(event.target) &&
event.key !== KEYS.ESCAPE &&
// handle cmd/ctrl-modifier shortcuts even inside inputs
!event[KEYS.CTRL_OR_CMD]) ||
// case: using arrows to move between buttons // case: using arrows to move between buttons
(isArrowKey(event.key) && isInputLike(event.target)) (isArrowKey(event.key) && isInputLike(event.target))
) { ) {

View File

@ -394,6 +394,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")} label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("G")]}
/> />
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
/>
<Shortcut
label={t("labels.increaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland> </ShortcutIsland>
</Column> </Column>
</Columns> </Columns>

View File

@ -21,9 +21,8 @@ import { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math"; import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { measureText } from "./textElement"; import { getContainerElement, measureText } from "./textElement";
import { isBoundToContainer } from "./typeChecks"; import { isBoundToContainer } from "./typeChecks";
import Scene from "../scene/Scene";
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING } from "../constants";
type ElementConstructorOpts = MarkOptional< type ElementConstructorOpts = MarkOptional<
@ -159,8 +158,8 @@ const getAdjustedDimensions = (
baseline: number; baseline: number;
} => { } => {
let maxWidth = null; let maxWidth = null;
if (element.containerId) { const container = getContainerElement(element);
const container = Scene.getScene(element)!.getElement(element.containerId)!; if (container) {
maxWidth = container.width - BOUND_TEXT_PADDING * 2; maxWidth = container.width - BOUND_TEXT_PADDING * 2;
} }
const { const {
@ -220,7 +219,7 @@ const getAdjustedDimensions = (
// make sure container dimensions are set properly when // make sure container dimensions are set properly when
// text editor overflows beyond viewport dimensions // text editor overflows beyond viewport dimensions
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
const container = Scene.getScene(element)!.getElement(element.containerId)!; const container = getContainerElement(element)!;
let height = container.height; let height = container.height;
let width = container.width; let width = container.width;
if (nextHeight > height - BOUND_TEXT_PADDING * 2) { if (nextHeight > height - BOUND_TEXT_PADDING * 2) {

View File

@ -416,9 +416,25 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
} }
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) { if (boundTextElementId) {
return Scene.getScene(element)!.getElement( return (
(Scene.getScene(element)?.getElement(
boundTextElementId, boundTextElementId,
) as ExcalidrawTextElementWithContainer; ) as ExcalidrawTextElementWithContainer) || null
);
}
return null;
};
export const getContainerElement = (
element:
| (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
| null,
) => {
if (!element) {
return null;
}
if (element.containerId) {
return Scene.getScene(element)?.getElement(element.containerId) || null;
} }
return null; return null;
}; };

View File

@ -8,16 +8,13 @@ import {
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isBoundToContainer, isTextElement } from "./typeChecks"; import { isBoundToContainer, isTextElement } from "./typeChecks";
import { CLASSES, BOUND_TEXT_PADDING } from "../constants"; import { CLASSES, BOUND_TEXT_PADDING } from "../constants";
import { import { ExcalidrawElement, ExcalidrawTextElement } from "./types";
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawTextElement,
} from "./types";
import { AppState } from "../types"; import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
getApproxLineHeight, getApproxLineHeight,
getBoundTextElementId, getBoundTextElementId,
getContainerElement,
wrapText, wrapText,
} from "./textElement"; } from "./textElement";
@ -102,9 +99,7 @@ export const textWysiwyg = ({
if (updatedElement && isTextElement(updatedElement)) { if (updatedElement && isTextElement(updatedElement)) {
let coordX = updatedElement.x; let coordX = updatedElement.x;
let coordY = updatedElement.y; let coordY = updatedElement.y;
const container = updatedElement?.containerId const container = getContainerElement(updatedElement);
? Scene.getScene(updatedElement)!.getElement(updatedElement.containerId)
: null;
let maxWidth = updatedElement.width; let maxWidth = updatedElement.width;
let maxHeight = updatedElement.height; let maxHeight = updatedElement.height;
@ -274,9 +269,7 @@ export const textWysiwyg = ({
let height = "auto"; let height = "auto";
if (lines === 2) { if (lines === 2) {
const container = Scene.getScene(element)!.getElement( const container = getContainerElement(element);
element.containerId,
);
const actualLineCount = wrapText( const actualLineCount = wrapText(
editable.value, editable.value,
getFontString(element), getFontString(element),
@ -300,13 +293,16 @@ export const textWysiwyg = ({
} }
editable.onkeydown = (event) => { editable.onkeydown = (event) => {
if (!event[KEYS.CTRL_OR_CMD]) {
event.stopPropagation(); event.stopPropagation();
}
if (event.key === KEYS.ESCAPE) { if (event.key === KEYS.ESCAPE) {
event.preventDefault(); event.preventDefault();
submittedViaKeyboard = true; submittedViaKeyboard = true;
handleSubmit(); handleSubmit();
} else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) { } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
if (event.isComposing || event.keyCode === 229) { if (event.isComposing || event.keyCode === 229) {
return; return;
} }
@ -319,6 +315,7 @@ export const textWysiwyg = ({
event.code === CODES.BRACKET_RIGHT)) event.code === CODES.BRACKET_RIGHT))
) { ) {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
outdent(); outdent();
} else { } else {
@ -443,9 +440,7 @@ export const textWysiwyg = ({
} }
let wrappedText = ""; let wrappedText = "";
if (isTextElement(updateElement) && updateElement?.containerId) { if (isTextElement(updateElement) && updateElement?.containerId) {
const container = Scene.getScene(updateElement)!.getElement( const container = getContainerElement(updateElement);
updateElement.containerId,
) as ExcalidrawBindableElement;
if (container) { if (container) {
wrappedText = wrapText( wrappedText = wrapText(

View File

@ -40,6 +40,10 @@ export const KEYS = {
QUESTION_MARK: "?", QUESTION_MARK: "?",
SPACE: " ", SPACE: " ",
TAB: "Tab", TAB: "Tab",
CHEVRON_LEFT: "<",
CHEVRON_RIGHT: ">",
PERIOD: ".",
COMMA: ",",
A: "a", A: "a",
D: "d", D: "d",

View File

@ -102,7 +102,9 @@
"showBackground": "Show background color picker", "showBackground": "Show background color picker",
"toggleTheme": "Toggle theme", "toggleTheme": "Toggle theme",
"personalLib": "Personal Library", "personalLib": "Personal Library",
"excalidrawLib": "Excalidraw Library" "excalidrawLib": "Excalidraw Library",
"decreaseFontSize": "Decrease font size",
"increaseFontSize": "Increase font size"
}, },
"buttons": { "buttons": {
"clearReset": "Reset the canvas", "clearReset": "Reset the canvas",