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:
Keavon Chambers 2022-08-03 14:12:28 -07:00
parent fa461f3157
commit f39d6bf00c
73 changed files with 1686 additions and 727 deletions

View 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

View file

@ -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

View file

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

View file

@ -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) {

View file

@ -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: {

View file

@ -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,
}))
);

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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,

View file

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

View file

@ -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"];
}

View file

@ -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>

View file

@ -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 },

View file

@ -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

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

View file

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

View file

@ -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.
}

View file

@ -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"

View file

@ -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(())

View file

@ -122,6 +122,7 @@ pub fn translate_key(name: &str) -> Key {
"capslock" => KeyShift,
" " => KeySpace,
"control" => KeyControl,
"command" => KeyCommand,
"delete" => KeyDelete,
"backspace" => KeyBackspace,
"alt" => KeyAlt,