mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-03 21:08:18 +00:00
Add full support for Mac-specific keyboard layouts (#736)
* IPP for Mac, flawed initial experiments * Cleanup and progress, but not compiling yet * Fix error and rename nonmac -> standard * Extentd ipp macros to accomodate mac input * Add Mac versions of shortcuts; refactor and document the input mapper macros * Change frontend styling for user input labels in floating menus * Additional macro documentation * A little more documentation * Improve entry macro syntax * Move input mapper macros to a separate file * Adapt the keyboard shortcuts to the user's OS * Display keyboard shortcuts in the menu bar based on OS * Change Input Mapper macro syntax from {} to () * Fix esc key bug in Vue * Tweaks * Interim solution for Mac-specific hints * Feed tooltip input hotkeys from their actions * Fix hotkeys for tools because of missing actions * Make Vue respect Ctrl/Cmd differences per platform * Remove commented lines * Code review pass by me * Code review suggestions with TrueDoctor * Turn FutureKeyMapping struct into ActionKeys enum which is a bit cleaner * Add serde derive attributes for message discriminants * Re-add serde deserialize * Fix not mutating ActionKeys conversion; remove custom serializer * Add serde to dev dependencies Co-authored-by: Dennis <dennis@kobert.dev>
This commit is contained in:
parent
fa461f3157
commit
f39d6bf00c
73 changed files with 1686 additions and 727 deletions
3
frontend/assets/icon-12px-solid/keyboard-control.svg
Normal file
3
frontend/assets/icon-12px-solid/keyboard-control.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<polygon points="6,1 1,5 1.6,5.8 6,2.3 10.4,5.8 11,5" />
|
||||
</svg>
|
After Width: | Height: | Size: 126 B |
|
@ -230,6 +230,7 @@ import { createFullscreenState, FullscreenState } from "@/state-providers/fullsc
|
|||
import { createPanelsState, PanelsState } from "@/state-providers/panels";
|
||||
import { createPortfolioState, PortfolioState } from "@/state-providers/portfolio";
|
||||
import { createWorkspaceState, WorkspaceState } from "@/state-providers/workspace";
|
||||
import { operatingSystem } from "@/utility-functions/platform";
|
||||
import { createEditor, Editor } from "@/wasm-communication/editor";
|
||||
|
||||
import MainWindow from "@/components/window/MainWindow.vue";
|
||||
|
@ -293,7 +294,8 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready
|
||||
this.editor.instance.init_after_frontend_ready();
|
||||
const platform = operatingSystem();
|
||||
this.editor.instance.init_after_frontend_ready(platform);
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Call the destructor for each manager
|
||||
|
|
|
@ -33,8 +33,7 @@
|
|||
|
||||
<span class="entry-label" :style="{ fontFamily: `${!entry.font ? 'inherit' : entry.value}` }">{{ entry.label }}</span>
|
||||
|
||||
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.state.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
|
||||
<UserInputLabel v-else-if="entry.shortcut?.length" :inputKeys="[entry.shortcut]" />
|
||||
<UserInputLabel v-if="entry.shortcut?.keys.length" :inputKeys="[entry.shortcut.keys]" :requiresLock="entry.shortcutRequiresLock" />
|
||||
|
||||
<div class="submenu-arrow" v-if="entry.children?.length"></div>
|
||||
<div class="no-submenu-arrow" v-else></div>
|
||||
|
@ -64,6 +63,10 @@
|
|||
.floating-menu-container .floating-menu-content {
|
||||
padding: 4px 0;
|
||||
|
||||
.separator div {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
.scroll-spacer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
@ -128,29 +131,24 @@
|
|||
&.open,
|
||||
&.active {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
svg {
|
||||
.entry-icon svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--color-8-uppergray);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
@ -172,11 +170,7 @@ import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
|||
import Separator from "@/components/widgets/labels/Separator.vue";
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
|
||||
|
||||
const KEYBOARD_LOCK_USE_FULLSCREEN = "This hotkey is reserved by the browser, but becomes available in fullscreen mode";
|
||||
const KEYBOARD_LOCK_SWITCH_BROWSER = "This hotkey is reserved by the browser, but becomes available in Chrome, Edge, and Opera which support the Keyboard.lock() API";
|
||||
|
||||
const MenuList = defineComponent({
|
||||
inject: ["fullscreen"],
|
||||
emits: ["update:open", "update:activeEntry", "naturalWidth"],
|
||||
props: {
|
||||
entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
|
||||
|
@ -193,7 +187,6 @@ const MenuList = defineComponent({
|
|||
data() {
|
||||
return {
|
||||
isOpen: this.open,
|
||||
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
|
||||
highlighted: this.activeEntry as MenuListEntry | undefined,
|
||||
virtualScrollingEntriesStart: 0,
|
||||
};
|
||||
|
|
|
@ -31,14 +31,18 @@
|
|||
<LayoutRow
|
||||
class="layer"
|
||||
:class="{ selected: listing.entry.layer_metadata.selected }"
|
||||
@click.shift.exact.stop="!listing.editingName && selectLayer(listing.entry, false, true)"
|
||||
@click.shift.ctrl.exact.stop="!listing.editingName && selectLayer(listing.entry, true, true)"
|
||||
@click.ctrl.exact.stop="!listing.editingName && selectLayer(listing.entry, true, false)"
|
||||
@click.exact.stop="!listing.editingName && selectLayer(listing.entry, false, false)"
|
||||
:data-index="index"
|
||||
:title="`${listing.entry.name}${devMode ? '\nLayer Path: ' + listing.entry.path.join(' / ') : ''}` || null"
|
||||
:draggable="draggable"
|
||||
@dragstart="(e) => draggable && dragStart(e, listing.entry)"
|
||||
:title="`${listing.entry.name}\n${devMode ? 'Layer Path: ' + listing.entry.path.join(' / ') : ''}`.trim() || null"
|
||||
@dragstart="(e) => draggable && dragStart(e, listing)"
|
||||
@click.exact="(e) => selectLayer(false, false, false, listing, e)"
|
||||
@click.shift.exact="(e) => selectLayer(false, false, true, listing, e)"
|
||||
@click.ctrl.exact="(e) => selectLayer(true, false, false, listing, e)"
|
||||
@click.ctrl.shift.exact="(e) => selectLayer(true, false, true, listing, e)"
|
||||
@click.meta.exact="(e) => selectLayer(false, true, false, listing, e)"
|
||||
@click.meta.shift.exact="(e) => selectLayer(false, true, true, listing, e)"
|
||||
@click.ctrl.meta="(e) => e.stopPropagation()"
|
||||
@click.alt="(e) => e.stopPropagation()"
|
||||
>
|
||||
<LayoutRow class="layer-type-icon">
|
||||
<IconLabel v-if="listing.entry.layer_type === 'Folder'" :icon="'NodeFolder'" :iconStyle="'Node'" title="Folder" />
|
||||
|
@ -53,10 +57,10 @@
|
|||
:value="listing.entry.name"
|
||||
:placeholder="listing.entry.layer_type"
|
||||
:disabled="!listing.editingName"
|
||||
@change="(e) => onEditLayerNameChange(listing, e.target)"
|
||||
@blur="() => onEditLayerNameDeselect(listing)"
|
||||
@keydown.esc="onEditLayerNameDeselect(listing)"
|
||||
@keydown.enter="(e) => onEditLayerNameChange(listing, e.target)"
|
||||
@keydown.escape="onEditLayerNameDeselect(listing)"
|
||||
@change="(e) => onEditLayerNameChange(listing, e.target)"
|
||||
/>
|
||||
</LayoutRow>
|
||||
<div class="thumbnail" v-html="listing.entry.thumbnail"></div>
|
||||
|
@ -263,6 +267,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, nextTick } from "vue";
|
||||
|
||||
import { operatingSystemIsMac } from "@/utility-functions/platform";
|
||||
import { defaultWidgetLayout, UpdateDocumentLayerTreeStructure, UpdateDocumentLayerDetails, UpdateLayerTreeOptionsLayout, LayerPanelEntry } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
|
@ -342,8 +347,18 @@ export default defineComponent({
|
|||
await nextTick();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
},
|
||||
async selectLayer(clickedLayer: LayerPanelEntry, ctrl: boolean, shift: boolean) {
|
||||
this.editor.instance.select_layer(clickedLayer.path, ctrl, shift);
|
||||
async selectLayer(ctrl: boolean, cmd: boolean, shift: boolean, listing: LayerListingInfo, event: Event) {
|
||||
if (listing.editingName) return;
|
||||
|
||||
const ctrlOrCmd = operatingSystemIsMac() ? cmd : ctrl;
|
||||
// Pressing the Ctrl key on a Mac, or the Cmd key on another platform, is a violation of the `.exact` qualifier so we filter it out here
|
||||
const opposite = operatingSystemIsMac() ? ctrl : cmd;
|
||||
|
||||
if (!opposite) this.editor.instance.select_layer(listing.entry.path, ctrlOrCmd, shift);
|
||||
|
||||
// We always want to stop propagation so the click event doesn't pass through the layer and cause a deselection by clicking the layer panel background
|
||||
// This is also why we cover the remaining cases not considered by the `.exact` qualifier, in the last two bindings on the layer element, with a `stopPropagation()` call
|
||||
event.stopPropagation();
|
||||
},
|
||||
async deselectAllLayers() {
|
||||
this.editor.instance.deselect_all_layers();
|
||||
|
@ -409,8 +424,9 @@ export default defineComponent({
|
|||
|
||||
return { insertFolder, insertIndex, highlightFolder, markerHeight };
|
||||
},
|
||||
async dragStart(event: DragEvent, layer: LayerPanelEntry) {
|
||||
if (!layer.layer_metadata.selected) this.selectLayer(layer, event.ctrlKey, event.shiftKey);
|
||||
async dragStart(event: DragEvent, listing: LayerListingInfo) {
|
||||
const layer = listing.entry;
|
||||
if (!layer.layer_metadata.selected) this.selectLayer(event.ctrlKey, event.metaKey, event.shiftKey, listing, event);
|
||||
|
||||
// Set style of cursor for drag
|
||||
if (event.dataTransfer) {
|
||||
|
|
|
@ -29,7 +29,8 @@
|
|||
@focus="() => $emit('textFocused')"
|
||||
@blur="() => $emit('textChanged')"
|
||||
@change="() => $emit('textChanged')"
|
||||
@keydown.ctrl.enter="() => $emit('textChanged')"
|
||||
@keydown.ctrl.enter="() => !macKeyboardLayout && $emit('textChanged')"
|
||||
@keydown.meta.enter="() => macKeyboardLayout && $emit('textChanged')"
|
||||
@keydown.esc="() => $emit('cancelTextChange')"
|
||||
></textarea>
|
||||
<label v-if="label" :for="`field-input-${id}`">{{ label }}</label>
|
||||
|
@ -116,6 +117,8 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { operatingSystemIsMac } from "@/utility-functions/platform";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -130,6 +133,7 @@ export default defineComponent({
|
|||
data() {
|
||||
return {
|
||||
id: `${Math.random()}`.substring(2),
|
||||
macKeyboardLayout: operatingSystemIsMac(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -101,7 +101,7 @@ export default defineComponent({
|
|||
...entry,
|
||||
children: entry.children ? menuEntryToFrontendMenuEntry(entry.children) : undefined,
|
||||
action: (): void => this.editor.instance.update_layout(updateMenuBarLayout.layout_target, entry.action.widgetId, undefined),
|
||||
shortcutRequiresLock: entry.shortcut ? shortcutRequiresLock(entry.shortcut) : undefined,
|
||||
shortcutRequiresLock: entry.shortcut?.keys ? shortcutRequiresLock(entry.shortcut.keys) : undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<LayoutRow class="user-input-label">
|
||||
<IconLabel class="user-input-label keyboard-lock-notice" v-if="displayKeyboardLockNotice" :icon="'Info'" :title="keyboardLockInfoMessage" />
|
||||
<LayoutRow class="user-input-label" v-else>
|
||||
<template v-for="(keyGroup, keyGroupIndex) in inputKeys" :key="keyGroupIndex">
|
||||
<span class="group-gap" v-if="keyGroupIndex > 0"></span>
|
||||
<template v-for="(keyInfo, index) in keyTextOrIconList(keyGroup)" :key="index">
|
||||
|
@ -45,15 +46,14 @@
|
|||
font-family: "Inconsolata", monospace;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
color: var(--color-e-nearwhite);
|
||||
border: 1px;
|
||||
box-sizing: border-box;
|
||||
border-style: solid;
|
||||
border-color: var(--color-7-middlegray);
|
||||
border-radius: 4px;
|
||||
height: 16px;
|
||||
// Firefox renders the text 1px lower than Chrome (tested on Windows) with 16px line-height, so moving it up 1 pixel by using 15px makes them agree
|
||||
line-height: 15px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
border-color: var(--color-7-middlegray);
|
||||
color: var(--color-e-nearwhite);
|
||||
|
||||
&.width-16 {
|
||||
width: 16px;
|
||||
|
@ -93,6 +93,40 @@
|
|||
.hint-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.floating-menu-content & {
|
||||
.input-key {
|
||||
border-color: var(--color-4-dimgray);
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.input-key .icon-label svg,
|
||||
&.keyboard-lock-notice.keyboard-lock-notice svg,
|
||||
.input-mouse .bright {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.input-mouse .dim {
|
||||
fill: var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu-content .row:hover > & {
|
||||
.input-key {
|
||||
border-color: var(--color-7-middlegray);
|
||||
color: var(--color-9-palegray);
|
||||
}
|
||||
|
||||
.input-key .icon-label svg,
|
||||
&.keyboard-lock-notice.keyboard-lock-notice svg,
|
||||
.input-mouse .bright {
|
||||
fill: var(--color-9-palegray);
|
||||
}
|
||||
|
||||
.input-mouse .dim {
|
||||
fill: var(--color-7-middlegray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
@ -100,92 +134,123 @@
|
|||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
import { operatingSystemIsMac } from "@/utility-functions/platform";
|
||||
import { HintInfo, KeysGroup } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
// Definitions
|
||||
const textMap = {
|
||||
Shift: "Shift",
|
||||
Control: "Ctrl",
|
||||
Alt: "Alt",
|
||||
Delete: "Del",
|
||||
PageUp: "PgUp",
|
||||
PageDown: "PgDn",
|
||||
Equals: "=",
|
||||
Minus: "-",
|
||||
Plus: "+",
|
||||
Escape: "Esc",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
LeftBracket: "[",
|
||||
RightBracket: "]",
|
||||
LeftCurlyBracket: "{",
|
||||
RightCurlyBracket: "}",
|
||||
};
|
||||
const iconsAndWidthsStandard = {
|
||||
ArrowUp: 1,
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: 1,
|
||||
Backspace: 2,
|
||||
Enter: 2,
|
||||
Tab: 2,
|
||||
Space: 3,
|
||||
};
|
||||
const iconsAndWidthsMac = {
|
||||
Shift: 2,
|
||||
Control: 2,
|
||||
Option: 2,
|
||||
Command: 2,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconLabel,
|
||||
LayoutRow,
|
||||
},
|
||||
inject: ["fullscreen"],
|
||||
props: {
|
||||
inputKeys: { type: Array as PropType<HintInfo["key_groups"]>, default: () => [] },
|
||||
inputKeys: { type: Array as PropType<HintInfo["keyGroups"]>, default: () => [] },
|
||||
inputMouse: { type: String as PropType<HintInfo["mouse"]>, default: null },
|
||||
requiresLock: { type: Boolean as PropType<boolean>, default: false },
|
||||
},
|
||||
computed: {
|
||||
hasSlotContent(): boolean {
|
||||
return Boolean(this.$slots.default);
|
||||
},
|
||||
keyboardLockInfoMessage(): string {
|
||||
const USE_FULLSCREEN = "This hotkey is reserved by the browser, but becomes available in fullscreen mode";
|
||||
const SWITCH_BROWSER = "This hotkey is reserved by the browser, but becomes available in Chrome, Edge, and Opera which support the Keyboard.lock() API";
|
||||
|
||||
return this.fullscreen.keyboardLockApiSupported ? USE_FULLSCREEN : SWITCH_BROWSER;
|
||||
},
|
||||
displayKeyboardLockNotice(): boolean {
|
||||
return this.requiresLock && !this.fullscreen.state.keyboardLocked;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
keyTextOrIconList(keyGroup: KeysGroup): { text: string | null; icon: IconName | null; width: string }[] {
|
||||
return keyGroup.map((inputKey) => this.keyTextOrIcon(inputKey));
|
||||
},
|
||||
keyTextOrIcon(keyText: string): { text: string | null; icon: IconName | null; width: string } {
|
||||
// Definitions
|
||||
const textMap: Record<string, string> = {
|
||||
Control: "Ctrl",
|
||||
Alt: "Alt",
|
||||
Delete: "Del",
|
||||
PageUp: "PgUp",
|
||||
PageDown: "PgDn",
|
||||
Equals: "=",
|
||||
Minus: "-",
|
||||
Plus: "+",
|
||||
Escape: "Esc",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
LeftBracket: "[",
|
||||
RightBracket: "]",
|
||||
LeftCurlyBracket: "{",
|
||||
RightCurlyBracket: "}",
|
||||
};
|
||||
const iconsAndWidths: Record<string, number> = {
|
||||
ArrowUp: 1,
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: 1,
|
||||
Backspace: 2,
|
||||
Command: 2,
|
||||
Enter: 2,
|
||||
Option: 2,
|
||||
Shift: 2,
|
||||
Tab: 2,
|
||||
Space: 3,
|
||||
};
|
||||
keyTextOrIcon(input: string): { text: string | null; icon: IconName | null; width: string } {
|
||||
let keyText = input;
|
||||
if (operatingSystemIsMac()) {
|
||||
keyText = keyText.replace("Alt", "Option");
|
||||
}
|
||||
|
||||
const iconsAndWidths = operatingSystemIsMac() ? { ...iconsAndWidthsStandard, ...iconsAndWidthsMac } : iconsAndWidthsStandard;
|
||||
|
||||
// Strip off the "Key" prefix
|
||||
const text = keyText.replace(/^(?:Key)?(.*)$/, "$1");
|
||||
|
||||
// If it's an icon, return the icon identifier
|
||||
if (text in iconsAndWidths) {
|
||||
if (Object.keys(iconsAndWidths).includes(text)) {
|
||||
// @ts-expect-error This is safe because of the if block we are in
|
||||
const width = iconsAndWidths[text] * 8 + 8;
|
||||
return {
|
||||
text: null,
|
||||
icon: this.keyboardHintIcon(text),
|
||||
width: `width-${iconsAndWidths[text] * 8 + 8}`,
|
||||
width: `width-${width}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, return the text string
|
||||
let result;
|
||||
|
||||
// Letters and numbers
|
||||
if (/^[A-Z0-9]$/.test(text)) result = text;
|
||||
if (/^[A-Z0-9]$/.test(text)) {
|
||||
result = text;
|
||||
}
|
||||
// Abbreviated names
|
||||
else if (text in textMap) result = textMap[text];
|
||||
else if (Object.keys(textMap).includes(text)) {
|
||||
// @ts-expect-error This is safe because of the if block we are in
|
||||
result = textMap[text];
|
||||
}
|
||||
// Other
|
||||
else result = text;
|
||||
else {
|
||||
result = text;
|
||||
}
|
||||
|
||||
return { text: result, icon: null, width: `width-${(result || " ").length * 8 + 8}` };
|
||||
},
|
||||
mouseHintIcon(input: HintInfo["mouse"]): IconName {
|
||||
return `MouseHint${input}` as IconName;
|
||||
},
|
||||
keyboardHintIcon(input: HintInfo["key_groups"][0][0]): IconName {
|
||||
keyboardHintIcon(input: HintInfo["keyGroups"][0][0]): IconName {
|
||||
return `Keyboard${input}` as IconName;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
IconLabel,
|
||||
LayoutRow,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<Separator :type="'Section'" v-if="index !== 0" />
|
||||
<template v-for="hint in hintGroup" :key="hint">
|
||||
<LayoutRow v-if="hint.plus" class="plus">+</LayoutRow>
|
||||
<UserInputLabel :inputMouse="hint.mouse" :inputKeys="hint.key_groups">{{ hint.label }}</UserInputLabel>
|
||||
<UserInputLabel :inputMouse="hint.mouse" :inputKeys="inputKeysForPlatform(hint)">{{ hint.label }}</UserInputLabel>
|
||||
</template>
|
||||
</template>
|
||||
</LayoutRow>
|
||||
|
@ -44,7 +44,8 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { HintData, UpdateInputHints } from "@/wasm-communication/messages";
|
||||
import { operatingSystemIsMac } from "@/utility-functions/platform";
|
||||
import { HintData, HintInfo, KeysGroup, UpdateInputHints } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import Separator from "@/components/widgets/labels/Separator.vue";
|
||||
|
@ -57,6 +58,12 @@ export default defineComponent({
|
|||
hintData: [] as HintData,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
inputKeysForPlatform(hint: HintInfo): KeysGroup[] {
|
||||
if (operatingSystemIsMac() && hint.keyGroupsMac) return hint.keyGroupsMac;
|
||||
return hint.keyGroups;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateInputHints, (updateInputHints) => {
|
||||
this.hintData = updateInputHints.hint_data;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<LayoutRow class="window-buttons-web" @click="() => handleClick()" :title="fullscreen.state.windowFullscreen ? 'Exit Fullscreen (F11)' : 'Enter Fullscreen (F11)'">
|
||||
<LayoutRow class="window-buttons-web" @click="() => handleClick()" :title="(fullscreen.state.windowFullscreen ? 'Exit' : 'Enter') + ' Fullscreen (F11)'">
|
||||
<TextLabel v-if="requestFullscreenHotkeys" :italic="true">Go fullscreen to access all hotkeys</TextLabel>
|
||||
<IconLabel :icon="fullscreen.state.windowFullscreen ? 'FullscreenExit' : 'FullscreenEnter'" />
|
||||
</LayoutRow>
|
||||
|
|
|
@ -45,8 +45,8 @@
|
|||
<Separator :type="'Unrelated'" />
|
||||
</LayoutCol>
|
||||
<LayoutCol>
|
||||
<UserInputLabel :inputKeys="[['KeyControl', 'KeyN']]" />
|
||||
<UserInputLabel :inputKeys="[['KeyControl', 'KeyO']]" />
|
||||
<UserInputLabel :inputKeys="[[controlOrCommandKey(), 'KeyN']]" />
|
||||
<UserInputLabel :inputKeys="[[controlOrCommandKey(), 'KeyO']]" />
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
@ -216,6 +216,8 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { operatingSystemIsMac } from "@/utility-functions/platform";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import Document from "@/components/panels/Document.vue";
|
||||
|
@ -257,6 +259,10 @@ export default defineComponent({
|
|||
openDocument() {
|
||||
this.editor.instance.document_open();
|
||||
},
|
||||
controlOrCommandKey() {
|
||||
// TODO: Remove this by properly feeding these keys from a layout provided by the backend
|
||||
return operatingSystemIsMac() ? "KeyCommand" : "KeyControl";
|
||||
},
|
||||
},
|
||||
components: {
|
||||
LayoutCol,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { DialogState } from "@/state-providers/dialog";
|
|||
import { FullscreenState } from "@/state-providers/fullscreen";
|
||||
import { PortfolioState } from "@/state-providers/portfolio";
|
||||
import { makeKeyboardModifiersBitfield, textInputCleanup, getLatinKey } from "@/utility-functions/keyboard-entry";
|
||||
import { operatingSystemIsMac } from "@/utility-functions/platform";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerPaste } from "@/wasm-communication/messages";
|
||||
|
@ -39,7 +40,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
|
|||
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(e) },
|
||||
{ target: window, eventName: "dblclick", action: (e: PointerEvent): void => onDoubleClick(e) },
|
||||
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onMouseScroll(e), options: { passive: false } },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onWheelScroll(e), options: { passive: false } },
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onModifyInputField(e) },
|
||||
{ target: window.document.body, eventName: "paste", action: (e: ClipboardEvent): void => onPaste(e) },
|
||||
{
|
||||
|
@ -69,13 +70,14 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
|
|||
const key = getLatinKey(e);
|
||||
if (!key) return false;
|
||||
|
||||
// TODO: Switch to a system where everything is sent to the backend, then the input preprocessor makes decisions and kicks some inputs back to the frontend
|
||||
const ctrlOrCmd = operatingSystemIsMac() ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
if (key !== "escape" && !(e.ctrlKey && key === "enter") && targetIsTextField(e.target)) {
|
||||
return false;
|
||||
}
|
||||
if (key !== "escape" && !(ctrlOrCmd && key === "enter") && targetIsTextField(e.target)) return false;
|
||||
|
||||
// Don't redirect paste
|
||||
if (key === "v" && e.ctrlKey) return false;
|
||||
if (key === "v" && ctrlOrCmd) return false;
|
||||
|
||||
// Don't redirect a fullscreen request
|
||||
if (key === "f11" && e.type === "keydown" && !e.repeat) {
|
||||
|
@ -85,13 +87,13 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
|
|||
}
|
||||
|
||||
// Don't redirect a reload request
|
||||
if (key === "f5") return false;
|
||||
if (key === "f5" || (ctrlOrCmd && key === "r")) return false;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (key === "f12" || key === "f8") return false;
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === "c") return false;
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === "i") return false;
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === "j") return false;
|
||||
if (ctrlOrCmd && e.shiftKey && key === "c") return false;
|
||||
if (ctrlOrCmd && e.shiftKey && key === "i") return false;
|
||||
if (ctrlOrCmd && e.shiftKey && key === "j") return false;
|
||||
|
||||
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
|
||||
if (!canvasFocused && !targetIsTextField(e.target) && ["tab", "enter", " ", "arrowdown", "arrowup", "arrowleft", "arrowright"].includes(key.toLowerCase())) return false;
|
||||
|
@ -200,7 +202,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
|
|||
if (e.button === 1) e.preventDefault();
|
||||
}
|
||||
|
||||
function onMouseScroll(e: WheelEvent): void {
|
||||
function onWheelScroll(e: WheelEvent): void {
|
||||
const { target } = e;
|
||||
const isTargetingCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||
|
||||
|
@ -215,7 +217,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
|
|||
if (isTargetingCanvas) {
|
||||
e.preventDefault();
|
||||
const modifiers = makeKeyboardModifiersBitfield(e);
|
||||
editor.instance.on_mouse_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
editor.instance.on_wheel_scroll(e.clientX, e.clientY, e.buttons, e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { TextButtonWidget } from "@/components/widgets/buttons/TextButton";
|
||||
import { DialogState } from "@/state-providers/dialog";
|
||||
import { IconName } from "@/utility-functions/icons";
|
||||
import { browserVersion, operatingSystem } from "@/utility-functions/platform";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
import { DisplayDialogPanic, Widget, WidgetLayout } from "@/wasm-communication/messages";
|
||||
|
@ -68,7 +69,7 @@ function githubUrl(panicDetails: string): string {
|
|||
Provide any further information or context that you think would be helpful in fixing the issue. Screenshots or video can be linked or attached to this issue.
|
||||
|
||||
**Browser and OS**
|
||||
${browserVersion()}, ${operatingSystem()}
|
||||
${browserVersion()}, ${operatingSystem(true).replace("Unknown", "YOUR OPERATING SYSTEM")}
|
||||
|
||||
**Stack Trace**
|
||||
Copied from the crash dialog in the Graphite Editor:
|
||||
|
@ -94,47 +95,3 @@ function githubUrl(panicDetails: string): string {
|
|||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function browserVersion(): string {
|
||||
const agent = window.navigator.userAgent;
|
||||
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
||||
|
||||
if (/trident/i.test(match[1])) {
|
||||
const browser = /\brv[ :]+(\d+)/g.exec(agent) || [];
|
||||
return `IE ${browser[1] || ""}`.trim();
|
||||
}
|
||||
|
||||
if (match[1] === "Chrome") {
|
||||
let browser = agent.match(/\bEdg\/(\d+)/);
|
||||
if (browser !== null) return `Edge (Chromium) ${browser[1]}`;
|
||||
|
||||
browser = agent.match(/\bOPR\/(\d+)/);
|
||||
if (browser !== null) return `Opera ${browser[1]}`;
|
||||
}
|
||||
|
||||
match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, "-?"];
|
||||
|
||||
const browser = agent.match(/version\/(\d+)/i);
|
||||
if (browser !== null) match.splice(1, 1, browser[1]);
|
||||
|
||||
return `${match[0]} ${match[1]}`;
|
||||
}
|
||||
|
||||
function operatingSystem(): string {
|
||||
const osTable: Record<string, string> = {
|
||||
"Windows NT 10": "Windows 10 or 11",
|
||||
"Windows NT 6.3": "Windows 8.1",
|
||||
"Windows NT 6.2": "Windows 8",
|
||||
"Windows NT 6.1": "Windows 7",
|
||||
"Windows NT 6.0": "Windows Vista",
|
||||
"Windows NT 5.1": "Windows XP",
|
||||
"Windows NT 5.0": "Windows 2000",
|
||||
Mac: "Mac",
|
||||
X11: "Unix",
|
||||
Linux: "Linux",
|
||||
Unknown: "YOUR OPERATING SYSTEM",
|
||||
};
|
||||
|
||||
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
|
||||
return osTable[userAgentOS || "Unknown"];
|
||||
}
|
||||
|
|
|
@ -14,10 +14,8 @@ import App from "@/App.vue";
|
|||
const body = document.body;
|
||||
const message = stripIndents`
|
||||
<style>
|
||||
h2, p, a {
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
h2, p, a { text-align: center; color: white; }
|
||||
#app { display: none; }
|
||||
</style>
|
||||
<h2>This browser is too old</h2>
|
||||
<p>Please upgrade to a modern web browser such as the latest Firefox, Chrome, Edge, or Safari version 15 or newer.</p>
|
||||
|
|
|
@ -21,6 +21,7 @@ import KeyboardArrowRight from "@/../assets/icon-12px-solid/keyboard-arrow-right
|
|||
import KeyboardArrowUp from "@/../assets/icon-12px-solid/keyboard-arrow-up.svg";
|
||||
import KeyboardBackspace from "@/../assets/icon-12px-solid/keyboard-backspace.svg";
|
||||
import KeyboardCommand from "@/../assets/icon-12px-solid/keyboard-command.svg";
|
||||
import KeyboardControl from "@/../assets/icon-12px-solid/keyboard-control.svg";
|
||||
import KeyboardEnter from "@/../assets/icon-12px-solid/keyboard-enter.svg";
|
||||
import KeyboardOption from "@/../assets/icon-12px-solid/keyboard-option.svg";
|
||||
import KeyboardShift from "@/../assets/icon-12px-solid/keyboard-shift.svg";
|
||||
|
@ -52,6 +53,7 @@ const SOLID_12PX = {
|
|||
KeyboardArrowUp: { component: KeyboardArrowUp, size: 12 },
|
||||
KeyboardBackspace: { component: KeyboardBackspace, size: 12 },
|
||||
KeyboardCommand: { component: KeyboardCommand, size: 12 },
|
||||
KeyboardControl: { component: KeyboardControl, size: 12 },
|
||||
KeyboardEnter: { component: KeyboardEnter, size: 12 },
|
||||
KeyboardOption: { component: KeyboardOption, size: 12 },
|
||||
KeyboardShift: { component: KeyboardShift, size: 12 },
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
export function makeKeyboardModifiersBitfield(e: WheelEvent | PointerEvent | KeyboardEvent): number {
|
||||
return Number(e.ctrlKey) | (Number(e.shiftKey) << 1) | (Number(e.altKey) << 2);
|
||||
return (
|
||||
// Shift (all platforms)
|
||||
(Number(e.shiftKey) << 0) |
|
||||
// Alt (all platforms, also called Option on Mac)
|
||||
(Number(e.altKey) << 1) |
|
||||
// Control (all platforms)
|
||||
(Number(e.ctrlKey) << 2) |
|
||||
// Meta (Windows/Linux) or Command (Mac)
|
||||
(Number(e.metaKey) << 3)
|
||||
);
|
||||
}
|
||||
|
||||
// Necessary because innerText puts an extra newline character at the end when the text is more than one line.
|
||||
|
@ -13,7 +22,7 @@ export function getLatinKey(e: KeyboardEvent): string | null {
|
|||
const key = e.key.toLowerCase();
|
||||
const isPrintable = !ALL_PRINTABLE_KEYS.has(e.key);
|
||||
|
||||
// Control (non-printable) characters are handled normally
|
||||
// Control characters (those which are non-printable) are handled normally
|
||||
if (!isPrintable) return key;
|
||||
|
||||
// These non-Latin characters should fall back to the Latin equivalent at the key location
|
||||
|
|
54
frontend/src/utility-functions/platform.ts
Normal file
54
frontend/src/utility-functions/platform.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
export function browserVersion(): string {
|
||||
const agent = window.navigator.userAgent;
|
||||
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
||||
|
||||
if (/trident/i.test(match[1])) {
|
||||
const browser = /\brv[ :]+(\d+)/g.exec(agent) || [];
|
||||
return `IE ${browser[1] || ""}`.trim();
|
||||
}
|
||||
|
||||
if (match[1] === "Chrome") {
|
||||
let browser = agent.match(/\bEdg\/(\d+)/);
|
||||
if (browser !== null) return `Edge (Chromium) ${browser[1]}`;
|
||||
|
||||
browser = agent.match(/\bOPR\/(\d+)/);
|
||||
if (browser !== null) return `Opera ${browser[1]}`;
|
||||
}
|
||||
|
||||
match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, "-?"];
|
||||
|
||||
const browser = agent.match(/version\/(\d+)/i);
|
||||
if (browser !== null) match.splice(1, 1, browser[1]);
|
||||
|
||||
return `${match[0]} ${match[1]}`;
|
||||
}
|
||||
|
||||
export function operatingSystem(detailed = false): string {
|
||||
const osTableDetailed: Record<string, string> = {
|
||||
"Windows NT 10": "Windows 10 or 11",
|
||||
"Windows NT 6.3": "Windows 8.1",
|
||||
"Windows NT 6.2": "Windows 8",
|
||||
"Windows NT 6.1": "Windows 7",
|
||||
"Windows NT 6.0": "Windows Vista",
|
||||
"Windows NT 5.1": "Windows XP",
|
||||
"Windows NT 5.0": "Windows 2000",
|
||||
Mac: "Mac",
|
||||
X11: "Unix",
|
||||
Linux: "Linux",
|
||||
Unknown: "Unknown",
|
||||
};
|
||||
const osTableSimple: Record<string, string> = {
|
||||
Windows: "Windows",
|
||||
Mac: "Mac",
|
||||
Linux: "Linux",
|
||||
Unknown: "Unknown",
|
||||
};
|
||||
const osTable = detailed ? osTableDetailed : osTableSimple;
|
||||
|
||||
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
|
||||
return osTable[userAgentOS || "Unknown"];
|
||||
}
|
||||
|
||||
export function operatingSystemIsMac(): boolean {
|
||||
return operatingSystem() === "Mac";
|
||||
}
|
|
@ -79,7 +79,9 @@ export type HintData = HintGroup[];
|
|||
export type HintGroup = HintInfo[];
|
||||
|
||||
export class HintInfo {
|
||||
readonly key_groups!: KeysGroup[];
|
||||
readonly keyGroups!: KeysGroup[];
|
||||
|
||||
readonly keyGroupsMac!: KeysGroup[] | null;
|
||||
|
||||
readonly mouse!: MouseMotion | null;
|
||||
|
||||
|
@ -404,12 +406,14 @@ export class ColorInput extends WidgetProps {
|
|||
tooltip!: string;
|
||||
}
|
||||
|
||||
export type Keys = { keys: string[] };
|
||||
|
||||
export interface MenuListEntryData<Value = string> {
|
||||
value?: Value;
|
||||
label?: string;
|
||||
icon?: IconName;
|
||||
font?: URL;
|
||||
shortcut?: string[];
|
||||
shortcut?: Keys;
|
||||
shortcutRequiresLock?: boolean;
|
||||
disabled?: boolean;
|
||||
action?: () => void;
|
||||
|
@ -781,7 +785,7 @@ export type MenuColumn = {
|
|||
};
|
||||
|
||||
export type MenuEntry = {
|
||||
shortcut: string[] | undefined;
|
||||
shortcut: Keys | undefined;
|
||||
action: Widget;
|
||||
label: string;
|
||||
icon: string | undefined;
|
||||
|
|
|
@ -79,7 +79,7 @@ function formatThirdPartyLicenses(jsLicenses) {
|
|||
if (process.env.NODE_ENV === "production" && process.env.SKIP_CARGO_ABOUT === undefined) {
|
||||
try {
|
||||
rustLicenses = generateRustLicenses();
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
// Nothing to show. Error messages were printed above.
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
name = "graphite-wasm"
|
||||
publish = false
|
||||
version = "0.0.0"
|
||||
rust-version = "1.56.0"
|
||||
rust-version = "1.62.0"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
edition = "2021"
|
||||
readme = "../../README.md"
|
||||
|
|
|
@ -6,6 +6,7 @@ use crate::helpers::{translate_key, Error};
|
|||
use crate::{EDITOR_HAS_CRASHED, EDITOR_INSTANCES, JS_EDITOR_HANDLES};
|
||||
|
||||
use editor::consts::{FILE_SAVE_SUFFIX, GRAPHITE_DOCUMENT_VERSION};
|
||||
use editor::document::utility_types::Platform;
|
||||
use editor::input::input_preprocessor::ModifierKeys;
|
||||
use editor::input::mouse::{EditorMouseState, ScrollDelta, ViewportBounds};
|
||||
use editor::message_prelude::*;
|
||||
|
@ -105,7 +106,15 @@ impl JsEditorHandle {
|
|||
// the backend from the web frontend.
|
||||
// ========================================================================
|
||||
|
||||
pub fn init_after_frontend_ready(&self) {
|
||||
pub fn init_after_frontend_ready(&self, platform: String) {
|
||||
let platform = match platform.as_str() {
|
||||
"Windows" => Platform::Windows,
|
||||
"Mac" => Platform::Mac,
|
||||
"Linux" => Platform::Linux,
|
||||
_ => Platform::Unknown,
|
||||
};
|
||||
|
||||
self.dispatch(PortfolioMessage::SetPlatform { platform });
|
||||
self.dispatch(Message::Init);
|
||||
}
|
||||
|
||||
|
@ -217,13 +226,13 @@ impl JsEditorHandle {
|
|||
}
|
||||
|
||||
/// Mouse scrolling within the screenspace bounds of the viewport
|
||||
pub fn on_mouse_scroll(&self, x: f64, y: f64, mouse_keys: u8, wheel_delta_x: i32, wheel_delta_y: i32, wheel_delta_z: i32, modifiers: u8) {
|
||||
pub fn on_wheel_scroll(&self, x: f64, y: f64, mouse_keys: u8, wheel_delta_x: i32, wheel_delta_y: i32, wheel_delta_z: i32, modifiers: u8) {
|
||||
let mut editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
||||
editor_mouse_state.scroll_delta = ScrollDelta::new(wheel_delta_x, wheel_delta_y, wheel_delta_z);
|
||||
|
||||
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||
|
||||
let message = InputPreprocessorMessage::MouseScroll { editor_mouse_state, modifier_keys };
|
||||
let message = InputPreprocessorMessage::WheelScroll { editor_mouse_state, modifier_keys };
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
|
@ -280,7 +289,7 @@ impl JsEditorHandle {
|
|||
|
||||
/// A text box was committed
|
||||
pub fn on_change_text(&self, new_text: String) -> Result<(), JsValue> {
|
||||
let message = TextMessage::TextChange { new_text };
|
||||
let message = TextToolMessage::TextChange { new_text };
|
||||
self.dispatch(message);
|
||||
|
||||
Ok(())
|
||||
|
@ -302,7 +311,7 @@ impl JsEditorHandle {
|
|||
|
||||
/// A text box was changed
|
||||
pub fn update_bounds(&self, new_text: String) -> Result<(), JsValue> {
|
||||
let message = TextMessage::UpdateBounds { new_text };
|
||||
let message = TextToolMessage::UpdateBounds { new_text };
|
||||
self.dispatch(message);
|
||||
|
||||
Ok(())
|
||||
|
|
|
@ -122,6 +122,7 @@ pub fn translate_key(name: &str) -> Key {
|
|||
"capslock" => KeyShift,
|
||||
" " => KeySpace,
|
||||
"control" => KeyControl,
|
||||
"command" => KeyCommand,
|
||||
"delete" => KeyDelete,
|
||||
"backspace" => KeyBackspace,
|
||||
"alt" => KeyAlt,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue