Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 6 additions & 31 deletions frontend/src/features/authoring/handlers/keyboard/keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,25 @@
import { redo, undo } from "../../scene/history";
import {
bringForward,
bringToFront,
duplicateComponent,
modifyComponentProp,
sendBackward,
sendToBack,
} from "../../scene/operations/component";
import { modifyComponentProp } from "../../scene/operations/component";
import { remove } from "../../scene/operations/modifiers";
import useEditorStore from "../../stores/editor";
import type { Vec2 } from "../../types";
import { translate } from "../../util";
import { handleShortcut } from "./shortcuts";
import { handleTextMode } from "./text";
import { isEditableShortcutTarget } from "./utils";

export function handleGlobal(e: KeyboardEvent) {
const mode = useEditorStore.getState().mode;
const { selected } = useEditorStore.getState();

// don't want to interfere with input elements
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
if (isEditableShortcutTarget(e.target)) return;

if (handleShortcut(e)) return;

if (mode.includes("text")) handleTextMode(e);
else if (e.ctrlKey || e.metaKey) handleCtrlOperations(e);
else if (selected) handleComponentOperations(e, selected);
}

function handleCtrlOperations(e: KeyboardEvent) {
const { selected, setSelected } = useEditorStore.getState();

// TODO ADD e.key === "a" for all components or create new function for cmd
if (e.key === "z") undo();
else if (e.key === "y") redo();
else if (e.key === "d" && selected) {
e.preventDefault();
const id = duplicateComponent(selected);
setSelected(id);
} else if (e.key === "ArrowUp" && selected) {
if (e.shiftKey) bringToFront(selected);
else bringForward(selected);
} else if (e.key === "ArrowDown" && selected) {
if (e.shiftKey) sendToBack(selected);
else sendBackward(selected);
}
}

function handleComponentOperations(e: KeyboardEvent, selected: string) {
const { setSelected } = useEditorStore.getState();

Expand Down
148 changes: 148 additions & 0 deletions frontend/src/features/authoring/handlers/keyboard/shortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { redo, undo } from "../../scene/history";
import {
bringForward,
bringToFront,
duplicateComponent,
sendBackward,
sendToBack,
} from "../../scene/operations/component";
import { remove } from "../../scene/operations/modifiers";
import useEditorStore from "../../stores/editor";
import { handleSelectAll } from "./text";
import { matchesShortcut } from "./utils";
import { setTextStyle } from "../../text/style";

type Shortcut = {
combos: string[];
when?: () => boolean;
run: () => void;
};

function toggleTextStyle(
selected: string,
prop: "fontWeight" | "fontStyle" | "textDecoration",
enabledValue: "bold" | "italic" | "underline",
disabledValue: "normal" | "none"
) {
const current = useEditorStore.getState().activeStyle;
const nextValue =
current?.[prop] === enabledValue ? disabledValue : enabledValue;
setTextStyle(selected, prop, nextValue);
}

const shortcuts: Shortcut[] = [
{
combos: ["mod+z"],
run: () => undo(),
},
{
combos: ["mod+shift+z", "mod+y"],
run: () => redo(),
},
{
combos: ["mod+d"],
when: () => Boolean(useEditorStore.getState().selected),
run: () => {
const { selected, setSelected } = useEditorStore.getState();
if (!selected) return;
setSelected(duplicateComponent(selected));
},
},
{
combos: ["backspace", "delete"],
when: () => {
const { mode, selected } = useEditorStore.getState();
return !mode.includes("text") && Boolean(selected);
},
run: () => {
const { selected, setSelected } = useEditorStore.getState();
if (!selected) return;
remove(selected);
setSelected(null);
},
},
{
combos: ["mod+arrowup"],
when: () => Boolean(useEditorStore.getState().selected),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
bringForward(selected);
},
},
{
combos: ["mod+shift+arrowup"],
when: () => Boolean(useEditorStore.getState().selected),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
bringToFront(selected);
},
},
{
combos: ["mod+arrowdown"],
when: () => Boolean(useEditorStore.getState().selected),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
sendBackward(selected);
},
},
{
combos: ["mod+shift+arrowdown"],
when: () => Boolean(useEditorStore.getState().selected),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
sendToBack(selected);
},
},
{
combos: ["mod+a"],
when: () => useEditorStore.getState().mode.includes("text"),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
handleSelectAll(selected);
},
},
{
combos: ["mod+b"],
when: () => useEditorStore.getState().mode.includes("text"),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
toggleTextStyle(selected, "fontWeight", "bold", "normal");
},
},
{
combos: ["mod+i"],
when: () => useEditorStore.getState().mode.includes("text"),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
toggleTextStyle(selected, "fontStyle", "italic", "normal");
},
},
{
combos: ["mod+u"],
when: () => useEditorStore.getState().mode.includes("text"),
run: () => {
const { selected } = useEditorStore.getState();
if (!selected) return;
toggleTextStyle(selected, "textDecoration", "underline", "none");
},
},
];

export function handleShortcut(e: KeyboardEvent) {
for (const shortcut of shortcuts) {
if (!shortcut.combos.some((combo) => matchesShortcut(e, combo))) continue;
if (shortcut.when && !shortcut.when()) continue;
e.preventDefault();
shortcut.run();
return true;
}

return false;
}
5 changes: 1 addition & 4 deletions frontend/src/features/authoring/handlers/keyboard/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ export function handleTextMode(e: KeyboardEvent) {
const { selected } = useEditorStore.getState();
if (!selected) return;

if ((e.metaKey || e.ctrlKey) && e.key == "a") {
e.preventDefault();
handleSelectAll(selected);
} else if (e.key.startsWith("Arrow") || ["Home", "End"].includes(e.key)) {
if (e.key.startsWith("Arrow") || ["Home", "End"].includes(e.key)) {
handleNavigation(e, selected);
} else {
handleEditing(e, selected);
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/features/authoring/handlers/keyboard/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const MAC_PLATFORMS = ["mac", "iphone", "ipad", "ipod"];

function getPlatform() {
return (
globalThis.navigator?.platform ??
globalThis.navigator?.userAgent ??
""
).toLowerCase();
Comment on lines +4 to +8

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return (
globalThis.navigator?.platform ??
globalThis.navigator?.userAgent ??
""
).toLowerCase();
const nav = globalThis.navigator;
return (nav?.platform || nav?.userAgent || "").toLowerCase();

navigator.platform can return empty string instead of null so || is safer than ?? to catch that

}

export function isMacPlatform() {
const platform = getPlatform();
return MAC_PLATFORMS.some((name) => platform.includes(name));
}
Comment on lines +11 to +14

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function isMacPlatform() {
const platform = getPlatform();
return MAC_PLATFORMS.some((name) => platform.includes(name));
}
let cachedIsMac: boolean | undefined;
export function isMacPlatform() {
if (cachedIsMac === undefined) {
const platform = getPlatform();
cachedIsMac = MAC_PLATFORMS.some((name) => platform.includes(name));
}
return cachedIsMac;
}

We don't need to check if is Mac for every keyboard press, once is enough.


export function isPrimaryShortcutModifier(e: KeyboardEvent) {
return isMacPlatform() ? e.metaKey : e.ctrlKey;
}

Comment on lines +16 to +19

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function isPrimaryShortcutModifier(e: KeyboardEvent) {
return isMacPlatform() ? e.metaKey : e.ctrlKey;
}
export function isPrimaryShortcutModifier(e: KeyboardEvent) {
return isMacPlatform() ? e.metaKey : e.ctrlKey;
}
export function isSecondaryShortcutModifier(e: KeyboardEvent) {
return isMacPlatform() ? e.ctrlKey : e.metaKey;
}

export function isEditableShortcutTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;

return (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
);
}

function normalizeKey(key: string) {
return key.trim().toLowerCase();
}

function hasOnlyRequestedModifiers(e: KeyboardEvent, modifiers: Set<string>) {
const usesPrimary = modifiers.has("mod");
const usesCtrl = modifiers.has("ctrl");
const usesMeta = modifiers.has("meta");
const usesShift = modifiers.has("shift");
const usesAlt = modifiers.has("alt") || modifiers.has("option");

if (usesPrimary) {
if (!isPrimaryShortcutModifier(e)) return false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!isPrimaryShortcutModifier(e)) return false;
if (!isPrimaryShortcutModifier(e)) return false;
if (isSecondaryShortcutModifier(e)) return false;

Also check the non-primary key is not held.
e.g. Cmd+Ctrl+Z on Mac and Ctrl+Win+Z on Windows should not activate undo.

} else {
if (usesCtrl !== e.ctrlKey) return false;
if (usesMeta !== e.metaKey) return false;
}
if (usesShift !== e.shiftKey) return false;
if (usesAlt !== e.altKey) return false;

return true;
}

export function matchesShortcut(e: KeyboardEvent, combo: string) {
if (e.repeat) return false;

const parts = combo
.toLowerCase()
.split("+")
.map((part) => part.trim())
.filter(Boolean);

const key = normalizeKey(parts.pop() ?? "");
const modifiers = new Set(parts);

if (!hasOnlyRequestedModifiers(e, modifiers)) return false;

return normalizeKey(e.key) === key;
}
38 changes: 38 additions & 0 deletions frontend/src/features/authoring/text/style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { modifyComponentProp } from "../scene/operations/component";
import { applySelectionStyle } from "../scene/operations/text";
import useEditorStore from "../stores/editor";
import { syncVisualCursor } from "./cursor";
import type { BaseTextStyle } from "../types";

type TextStyleValue = BaseTextStyle[keyof BaseTextStyle];

export function setTextStyle(
selected: string,
prop: keyof BaseTextStyle,
value: TextStyleValue
) {
const { selection, activeStyle, setActiveStyle, setSelection } =
useEditorStore.getState();

if (selection?.end) {
const newSelection = applySelectionStyle(selected, selection, {
[prop]: value,
});
setSelection(newSelection);
syncVisualCursor();
} else if (selection?.start) {
if (prop === "lineHeight" || prop === "alignment") {
modifyComponentProp(
selected,
`document.blocks.${selection.start.blockI}.style.${prop}`,
value
);
} else {
modifyComponentProp(selected, `document.style.${prop}`, value);
}
} else {
modifyComponentProp(selected, `document.style.${prop}`, value);
}

setActiveStyle({ ...(activeStyle ?? {}), [prop]: value } as BaseTextStyle);
}
26 changes: 2 additions & 24 deletions frontend/src/features/authoring/topbar/TextSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,18 @@ import NumberInput from "../wrapper/NumberInput";
import ToggleInput from "../wrapper/ToggleInput";
import ChromePicker from "../wrapper/ChromePicker";
import MultiInput from "../wrapper/MultiInput";
import { applySelectionStyle } from "../scene/operations/text";
import { modifyComponentProp } from "../scene/operations/component";
import type { BaseTextStyle } from "../types";
import { syncVisualCursor } from "../text/cursor";
import { setTextStyle } from "../text/style";

function TextSection() {
const selected = useEditorStore((state) => state.selected)!; // this comp only renders when a text el is selected
const selection = useEditorStore((state) => state.selection);

const style = useEditorStore((state) => state.activeStyle);
const setStyle = useEditorStore((state) => state.setActiveStyle);

if (!style) return null;

function modifyStyle(prop: keyof BaseTextStyle, value: string | number) {
if (selection?.end) {
const newSelection = applySelectionStyle(selected, selection, {
[prop]: value,
});
useEditorStore.getState().setSelection(newSelection);
syncVisualCursor();
} else if (selection?.start) {
if (prop === "lineHeight" || prop === "alignment") {
modifyComponentProp(
selected,
`document.blocks.${selection.start.blockI}.style.${prop}`,
value
);
}
} else {
modifyComponentProp(selected, `document.style.${prop}`, value);
}

setStyle({ ...style!, [prop]: value });
setTextStyle(selected, prop, value);
}

return (
Expand Down
Loading