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

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