diff --git a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts index f623c0c8..d667d7f0 100644 --- a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts @@ -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(); diff --git a/frontend/src/features/authoring/handlers/keyboard/shortcuts.ts b/frontend/src/features/authoring/handlers/keyboard/shortcuts.ts new file mode 100644 index 00000000..ff59af47 --- /dev/null +++ b/frontend/src/features/authoring/handlers/keyboard/shortcuts.ts @@ -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; +} diff --git a/frontend/src/features/authoring/handlers/keyboard/text.ts b/frontend/src/features/authoring/handlers/keyboard/text.ts index 3af194d1..3c90410d 100644 --- a/frontend/src/features/authoring/handlers/keyboard/text.ts +++ b/frontend/src/features/authoring/handlers/keyboard/text.ts @@ -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); diff --git a/frontend/src/features/authoring/handlers/keyboard/utils.ts b/frontend/src/features/authoring/handlers/keyboard/utils.ts new file mode 100644 index 00000000..748ff641 --- /dev/null +++ b/frontend/src/features/authoring/handlers/keyboard/utils.ts @@ -0,0 +1,67 @@ +const MAC_PLATFORMS = ["mac", "iphone", "ipad", "ipod"]; + +function getPlatform() { + return ( + globalThis.navigator?.platform ?? + globalThis.navigator?.userAgent ?? + "" + ).toLowerCase(); +} + +export function isMacPlatform() { + const platform = getPlatform(); + return MAC_PLATFORMS.some((name) => platform.includes(name)); +} + +export function isPrimaryShortcutModifier(e: KeyboardEvent) { + return isMacPlatform() ? e.metaKey : e.ctrlKey; +} + +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) { + 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; + } 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; +} diff --git a/frontend/src/features/authoring/text/style.ts b/frontend/src/features/authoring/text/style.ts new file mode 100644 index 00000000..d1eacc36 --- /dev/null +++ b/frontend/src/features/authoring/text/style.ts @@ -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); +} diff --git a/frontend/src/features/authoring/topbar/TextSection.tsx b/frontend/src/features/authoring/topbar/TextSection.tsx index e9fc81d7..4ee88e5e 100644 --- a/frontend/src/features/authoring/topbar/TextSection.tsx +++ b/frontend/src/features/authoring/topbar/TextSection.tsx @@ -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 (