-
Notifications
You must be signed in to change notification settings - Fork 1
Vps 16 add keyboards shortcuts to the editor #403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
61e8bf0
4c0fb23
9d0732a
075df04
319bd0c
84446ba
45b30f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } |
| 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)); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+11
to
+14
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Also check the non-primary key is not held. |
||||||||||||||||||||||||||||
| } 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; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| 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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
navigator.platform can return empty string instead of null so || is safer than ?? to catch that