Welcome screen, refactor to allow zero documents, and add TS typing to widgets (#702)

* unfinished implementation

* Add frontend for the empty panel screen

* Add an icon for Folder based on NodeFolder

* fixed messages causing peicees of ui not to render on new document

* Standardize nextTick syntax

* WIP generisization of component subscriptions (not compiling yet)

* Fix crash when loading font and there is no active document

* Only advertise tool actions with a document

* Fix failure to create new document

* Initalise the properties panel

* Fix highlight tab, canvas jump, warns and layer tree

* Fix tests

* Possibly fix some things?

* Move WorkingColors layout definition to backend

* Standardize action macro formatting

* Provide typing for widgets in TS/Vue and associated cleanup

* Fix viewport positioning initialization

* Fix menu bar init at startup not document creation

* Fix no viewport bounds bug

* Change !=0 to >0

* Simplify the init system

Closes #656

Co-authored-by: Keavon Chambers <keavon@keavon.com>
Co-authored-by: 0hypercube <0hypercube@gmail.com>
This commit is contained in:
mfish33 2022-07-22 16:09:13 -06:00 committed by Keavon Chambers
parent b4b667ded6
commit a0c22d20b6
77 changed files with 1859 additions and 1136 deletions

View file

@ -213,6 +213,7 @@ img {
<script lang="ts">
import { defineComponent } from "vue";
import { createBlobManager } from "@/io-managers/blob";
import { createClipboardManager } from "@/io-managers/clipboard";
import { createHyperlinkManager } from "@/io-managers/hyperlinks";
import { createInputManager } from "@/io-managers/input";
@ -222,6 +223,7 @@ import { createPersistenceManager } from "@/io-managers/persistence";
import { createDialogState, DialogState } from "@/state-providers/dialog";
import { createFontsState, FontsState } from "@/state-providers/fonts";
import { createFullscreenState, FullscreenState } from "@/state-providers/fullscreen";
import { createPanelsState, PanelsState } from "@/state-providers/panels";
import { createPortfolioState, PortfolioState } from "@/state-providers/portfolio";
import { createWorkspaceState, WorkspaceState } from "@/state-providers/workspace";
import { createEditor, Editor } from "@/wasm-communication/editor";
@ -229,6 +231,7 @@ import { createEditor, Editor } from "@/wasm-communication/editor";
import MainWindow from "@/components/window/MainWindow.vue";
const managerDestructors: {
createBlobManager?: () => void;
createClipboardManager?: () => void;
createHyperlinkManager?: () => void;
createInputManager?: () => void;
@ -248,6 +251,7 @@ declare module "@vue/runtime-core" {
dialog: DialogState;
fonts: FontsState;
fullscreen: FullscreenState;
panels: PanelsState;
portfolio: PortfolioState;
workspace: WorkspaceState;
}
@ -266,7 +270,8 @@ export default defineComponent({
// State provider systems
dialog: createDialogState(editor),
fonts: createFontsState(editor),
fullscreen: createFullscreenState(),
fullscreen: createFullscreenState(editor),
panels: createPanelsState(editor),
portfolio: createPortfolioState(editor),
workspace: createWorkspaceState(editor),
};
@ -274,6 +279,7 @@ export default defineComponent({
async mounted() {
// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
Object.assign(managerDestructors, {
createBlobManager: createBlobManager(this.editor),
createClipboardManager: createClipboardManager(this.editor),
createHyperlinkManager: createHyperlinkManager(this.editor),
createInputManager: createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen),
@ -283,7 +289,7 @@ 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_app();
this.editor.instance.init_after_frontend_ready();
},
beforeUnmount() {
// Call the destructor for each manager

View file

@ -175,7 +175,7 @@
</style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { defineComponent, nextTick, PropType } from "vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
@ -319,7 +319,7 @@ export default defineComponent({
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
async measureAndEmitNaturalWidth(): Promise<void> {
// Wait for the changed content which fired the `updated()` Vue event to be put into the DOM
await this.$nextTick();
await nextTick();
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready
@ -329,7 +329,8 @@ export default defineComponent({
// Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM
this.measuringOngoing = true;
this.measuringOngoingGuard = true;
await this.$nextTick();
await nextTick();
// Only measure if the menu is visible, perhaps because a parent component with a `v-if` condition is false
let naturalWidth;
@ -341,7 +342,7 @@ export default defineComponent({
// Turn off measuring mode for the component, which triggers another call to the `updated()` Vue event, so we can turn off the protection after that has happened
this.measuringOngoing = false;
await this.$nextTick();
await nextTick();
this.measuringOngoingGuard = false;
// Emit the measured natural width to the parent
@ -424,7 +425,7 @@ export default defineComponent({
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newState: boolean, oldState: boolean) {
async open(newState: boolean, oldState: boolean) {
// Switching from closed to open
if (newState && !oldState) {
// Close floating menu if pointer strays far enough away
@ -435,15 +436,17 @@ export default defineComponent({
window.addEventListener("pointerdown", this.pointerDownHandler);
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
window.addEventListener("pointerup", this.pointerUpHandler);
// Floating menu min-width resize observer
this.$nextTick(() => {
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
if (!floatingMenuContainer) return;
// Start a new observation of the now-open floating menu
this.containerResizeObserver.disconnect();
this.containerResizeObserver.observe(floatingMenuContainer);
});
// Floating menu min-width resize observer
await nextTick();
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
if (!floatingMenuContainer) return;
// Start a new observation of the now-open floating menu
this.containerResizeObserver.disconnect();
this.containerResizeObserver.observe(floatingMenuContainer);
}
// Switching from open to closed

View file

@ -163,7 +163,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName } from "@/utility-functions/icons";
import { MenuListEntry, SectionsOfMenuListEntries, MenuListEntryData } from "@/wasm-communication/messages";
import FloatingMenu, { MenuDirection } from "@/components/floating-menus/FloatingMenu.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
@ -172,23 +172,6 @@ import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import Separator from "@/components/widgets/labels/Separator.vue";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
export type MenuListEntries<Value = string> = MenuListEntry<Value>[];
export type SectionsOfMenuListEntries<Value = string> = MenuListEntries<Value>[];
interface MenuListEntryData<Value = string> {
value?: Value;
label?: string;
icon?: IconName;
font?: URL;
shortcut?: string[];
shortcutRequiresLock?: boolean;
disabled?: boolean;
action?: () => void;
children?: SectionsOfMenuListEntries;
}
export type MenuListEntry<Value = string> = MenuListEntryData<Value> & { ref?: typeof FloatingMenu | typeof MenuList };
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";

View file

@ -17,12 +17,7 @@
<LayoutCol class="spacer"></LayoutCol>
<LayoutCol class="working-colors">
<SwatchPairInput />
<LayoutRow class="swap-and-reset">
<!-- TODO: Remember to make these tooltip input hints customized to macOS also -->
<IconButton :action="swapWorkingColors" :icon="'Swap'" title="Swap (Shift+X)" :size="16" />
<IconButton :action="resetWorkingColors" :icon="'ResetColors'" title="Reset (Ctrl+Shift+X)" :size="16" />
</LayoutRow>
<WidgetLayout :layout="workingColorsLayout" />
</LayoutCol>
</LayoutCol>
<LayoutCol class="viewport">
@ -135,8 +130,16 @@
.working-colors {
flex: 0 0 auto;
.swap-and-reset {
flex: 0 0 auto;
.widget-row {
min-height: 0;
.swatch-pair {
margin: 0;
}
.icon-button {
--widget-height: 0;
}
}
}
}
@ -222,55 +225,26 @@ import { defineComponent, nextTick } from "vue";
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
import {
UpdateDocumentArtwork,
UpdateDocumentOverlays,
UpdateDocumentScrollbars,
UpdateDocumentRulers,
UpdateDocumentArtboards,
UpdateMouseCursor,
UpdateDocumentModeLayout,
UpdateToolOptionsLayout,
UpdateToolShelfLayout,
UpdateWorkingColorsLayout,
defaultWidgetLayout,
UpdateDocumentBarLayout,
UpdateImageData,
TriggerTextCommit,
TriggerViewportResize,
DisplayRemoveEditableTextbox,
DisplayEditableTextbox,
MouseCursorIcon,
XY,
} from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.vue";
import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.vue";
import PersistentScrollbar from "@/components/widgets/metrics/PersistentScrollbar.vue";
import WidgetLayout from "@/components/widgets/WidgetLayout.vue";
export default defineComponent({
inject: ["editor"],
inject: ["editor", "panels"],
methods: {
viewportResize() {
// Resize the canvas
const canvas = this.$refs.canvas as HTMLElement;
// Get the width and height rounded up to the nearest even number because resizing is centered and dividing an odd number by 2 for centering causes antialiasing
let width = Math.ceil(parseFloat(getComputedStyle(canvas).width));
if (width % 2 === 1) width += 1;
let height = Math.ceil(parseFloat(getComputedStyle(canvas).height));
if (height % 2 === 1) height += 1;
this.canvasSvgWidth = `${width}px`;
this.canvasSvgHeight = `${height}px`;
// Resize the rulers
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
const rulerVertical = this.$refs.rulerVertical as typeof CanvasRuler;
rulerHorizontal?.resize();
rulerVertical?.resize();
},
pasteFile(e: DragEvent) {
const { dataTransfer } = e;
if (!dataTransfer) return;
@ -304,12 +278,6 @@ export default defineComponent({
const move = delta < 0 ? 1 : -1;
this.editor.instance.translate_canvas_by_fraction(0, move);
},
swapWorkingColors() {
this.editor.instance.swap_colors();
},
resetWorkingColors() {
this.editor.instance.reset_colors();
},
canvasPointerDown(e: PointerEvent) {
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
if (!onEditbox) {
@ -317,76 +285,65 @@ export default defineComponent({
canvas.setPointerCapture(e.pointerId);
}
},
},
mounted() {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
this.artworkSvg = UpdateDocumentArtwork.svg;
// Update rendered SVGs
async updateDocumentArtwork(svg: string) {
this.artworkSvg = svg;
nextTick((): void => {
if (this.textInput) {
const canvas = this.$refs.canvas as HTMLElement;
const foreignObject = canvas.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
if (foreignObject.children.length > 0) return;
await nextTick();
const addedInput = foreignObject.appendChild(this.textInput);
nextTick((): void => {
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
const range = document.createRange();
range.selectNodeContents(addedInput);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
addedInput.focus();
addedInput.click();
});
window.dispatchEvent(
new CustomEvent("modifyinputfield", {
detail: addedInput,
})
);
}
});
});
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => {
this.overlaysSvg = updateDocumentOverlays.svg;
});
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, (updateDocumentArtboards) => {
this.artboardSvg = updateDocumentArtboards.svg;
});
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, (updateDocumentScrollbars) => {
this.scrollbarPos = updateDocumentScrollbars.position;
this.scrollbarSize = updateDocumentScrollbars.size;
this.scrollbarMultiplier = updateDocumentScrollbars.multiplier;
});
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, (updateDocumentRulers) => {
this.rulerOrigin = updateDocumentRulers.origin;
this.rulerSpacing = updateDocumentRulers.spacing;
this.rulerInterval = updateDocumentRulers.interval;
});
this.editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
this.canvasCursor = updateMouseCursor.cursor;
});
this.editor.subscriptions.subscribeJsMessage(TriggerTextCommit, () => {
if (this.textInput) {
const textCleaned = textInputCleanup(this.textInput.innerText);
this.editor.instance.on_change_text(textCleaned);
}
});
const canvas = this.$refs.canvas as HTMLElement;
const foreignObject = canvas.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
if (foreignObject.children.length > 0) return;
this.editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => {
const addedInput = foreignObject.appendChild(this.textInput);
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: addedInput }));
await nextTick();
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
const range = document.createRange();
range.selectNodeContents(addedInput);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
addedInput.focus();
addedInput.click();
}
},
updateDocumentOverlays(svg: string) {
this.overlaysSvg = svg;
},
updateDocumentArtboards(svg: string) {
this.artboardSvg = svg;
},
// Update scrollbars and rulers
updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
this.scrollbarPos = position;
this.scrollbarSize = size;
this.scrollbarMultiplier = multiplier;
},
updateDocumentRulers(origin: XY, spacing: number, interval: number) {
this.rulerOrigin = origin;
this.rulerSpacing = spacing;
this.rulerInterval = interval;
},
// Update mouse cursor icon
updateMouseCursor(cursor: MouseCursorIcon) {
this.canvasCursor = cursor;
},
// Text entry
triggerTextCommit() {
if (!this.textInput) return;
const textCleaned = textInputCleanup(this.textInput.innerText);
this.editor.instance.on_change_text(textCleaned);
},
displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
this.textInput = document.createElement("DIV") as HTMLDivElement;
if (displayEditableTextbox.text === "") this.textInput.textContent = "";
@ -399,49 +356,52 @@ export default defineComponent({
this.textInput.style.color = displayEditableTextbox.color.toRgbaCSS();
this.textInput.oninput = (): void => {
if (this.textInput) this.editor.instance.update_bounds(textInputCleanup(this.textInput.innerText));
if (!this.textInput) return;
this.editor.instance.update_bounds(textInputCleanup(this.textInput.innerText));
};
});
this.editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, () => {
},
displayRemoveEditableTextbox() {
this.textInput = undefined;
window.dispatchEvent(
new CustomEvent("modifyinputfield", {
detail: undefined,
})
);
});
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, (updateDocumentModeLayout) => {
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: undefined }));
},
// Update layouts
updateDocumentModeLayout(updateDocumentModeLayout: UpdateDocumentModeLayout) {
this.documentModeLayout = updateDocumentModeLayout;
});
this.editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, (updateToolOptionsLayout) => {
},
updateToolOptionsLayout(updateToolOptionsLayout: UpdateToolOptionsLayout) {
this.toolOptionsLayout = updateToolOptionsLayout;
});
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, (updateDocumentBarLayout) => {
},
updateDocumentBarLayout(updateDocumentBarLayout: UpdateDocumentBarLayout) {
this.documentBarLayout = updateDocumentBarLayout;
});
this.editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, (updateToolShelfLayout) => {
},
updateToolShelfLayout(updateToolShelfLayout: UpdateToolShelfLayout) {
this.toolShelfLayout = updateToolShelfLayout;
});
},
updateWorkingColorsLayout(updateWorkingColorsLayout: UpdateWorkingColorsLayout) {
this.workingColorsLayout = updateWorkingColorsLayout;
},
// Resize elements to render the new viewport size
viewportResize() {
// Resize the canvas
// Width and height are rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
const canvas = this.$refs.canvas as HTMLElement;
const width = Math.ceil(parseFloat(getComputedStyle(canvas).width));
const height = Math.ceil(parseFloat(getComputedStyle(canvas).height));
this.canvasSvgWidth = `${width % 2 === 1 ? width + 1 : width}px`;
this.canvasSvgHeight = `${height % 2 === 1 ? height + 1 : height}px`;
this.editor.subscriptions.subscribeJsMessage(TriggerViewportResize, this.viewportResize);
// Resize the rulers
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
const rulerVertical = this.$refs.rulerVertical as typeof CanvasRuler;
rulerHorizontal?.resize();
rulerVertical?.resize();
},
},
mounted() {
this.panels.registerPanel("Document", this);
this.editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
updateImageData.image_data.forEach(async (element) => {
// Using updateImageData.image_data.buffer returns undefined for some reason?
const blob = new Blob([new Uint8Array(element.image_data.values()).buffer], { type: element.mime });
const url = URL.createObjectURL(blob);
const image = await createImageBitmap(blob);
this.editor.instance.set_image_blob_url(element.path, url, image.width, image.height);
});
});
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
window.dispatchEvent(new Event("resize"));
},
data() {
return {
@ -449,39 +409,38 @@ export default defineComponent({
textInput: undefined as undefined | HTMLDivElement,
// CSS properties
canvasSvgWidth: "100%",
canvasSvgHeight: "100%",
canvasCursor: "default",
canvasSvgWidth: "100%" as string,
canvasSvgHeight: "100%" as string,
canvasCursor: "default" as MouseCursorIcon,
// Scrollbars
scrollbarPos: { x: 0.5, y: 0.5 },
scrollbarSize: { x: 0.5, y: 0.5 },
scrollbarMultiplier: { x: 0, y: 0 },
scrollbarPos: { x: 0.5, y: 0.5 } as XY,
scrollbarSize: { x: 0.5, y: 0.5 } as XY,
scrollbarMultiplier: { x: 0, y: 0 } as XY,
// Rulers
rulerOrigin: { x: 0, y: 0 },
rulerSpacing: 100,
rulerInterval: 100,
rulerOrigin: { x: 0, y: 0 } as XY,
rulerSpacing: 100 as number,
rulerInterval: 100 as number,
// Rendered SVG viewport data
artworkSvg: "",
artboardSvg: "",
overlaysSvg: "",
artworkSvg: "" as string,
artboardSvg: "" as string,
overlaysSvg: "" as string,
// Layouts
documentModeLayout: defaultWidgetLayout(),
toolOptionsLayout: defaultWidgetLayout(),
documentBarLayout: defaultWidgetLayout(),
toolShelfLayout: defaultWidgetLayout(),
workingColorsLayout: defaultWidgetLayout(),
};
},
components: {
LayoutRow,
LayoutCol,
SwatchPairInput,
PersistentScrollbar,
CanvasRuler,
IconButton,
WidgetLayout,
},
});

View file

@ -41,10 +41,10 @@
:title="`${listing.entry.name}\n${devMode ? 'Layer Path: ' + listing.entry.path.join(' / ') : ''}`.trim() || null"
>
<LayoutRow class="layer-type-icon">
<IconLabel v-if="listing.entry.layer_type === 'Folder'" :icon="'NodeFolder'" :style="'node'" title="Folder" />
<IconLabel v-else-if="listing.entry.layer_type === 'Image'" :icon="'NodeImage'" :style="'node'" title="Image" />
<IconLabel v-else-if="listing.entry.layer_type === 'Shape'" :icon="'NodeShape'" :style="'node'" title="Shape" />
<IconLabel v-else-if="listing.entry.layer_type === 'Text'" :icon="'NodeText'" :style="'node'" title="Path" />
<IconLabel v-if="listing.entry.layer_type === 'Folder'" :icon="'NodeFolder'" :iconStyle="'Node'" title="Folder" />
<IconLabel v-else-if="listing.entry.layer_type === 'Image'" :icon="'NodeImage'" :iconStyle="'Node'" title="Image" />
<IconLabel v-else-if="listing.entry.layer_type === 'Shape'" :icon="'NodeShape'" :iconStyle="'Node'" title="Shape" />
<IconLabel v-else-if="listing.entry.layer_type === 'Text'" :icon="'NodeText'" :iconStyle="'Node'" title="Path" />
</LayoutRow>
<LayoutRow class="layer-name" @dblclick="() => onEditLayerName(listing)">
<input
@ -261,7 +261,7 @@
</style>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, nextTick } from "vue";
import { defaultWidgetLayout, UpdateDocumentLayerTreeStructure, UpdateDocumentLayerDetails, UpdateLayerTreeOptionsLayout, LayerPanelEntry } from "@/wasm-communication/messages";
@ -313,16 +313,16 @@ export default defineComponent({
handleExpandArrowClick(path: BigUint64Array) {
this.editor.instance.toggle_layer_expansion(path);
},
onEditLayerName(listing: LayerListingInfo) {
async onEditLayerName(listing: LayerListingInfo) {
if (listing.editingName) return;
this.draggable = false;
listing.editingName = true;
const tree: HTMLElement = (this.$refs.layerTreeList as typeof LayoutCol).$el;
this.$nextTick(() => {
(tree.querySelector("[data-text-input]:not([disabled])") as HTMLInputElement).select();
});
await nextTick();
(tree.querySelector("[data-text-input]:not([disabled])") as HTMLInputElement).select();
},
onEditLayerNameChange(listing: LayerListingInfo, inputElement: EventTarget | null) {
// Eliminate duplicate events
@ -334,13 +334,13 @@ export default defineComponent({
listing.editingName = false;
this.editor.instance.set_layer_name(listing.entry.path, name);
},
onEditLayerNameDeselect(listing: LayerListingInfo) {
async onEditLayerNameDeselect(listing: LayerListingInfo) {
this.draggable = true;
listing.editingName = false;
this.$nextTick(() => {
window.getSelection()?.removeAllRanges();
});
await nextTick();
window.getSelection()?.removeAllRanges();
},
async selectLayer(clickedLayer: LayerPanelEntry, ctrl: boolean, shift: boolean) {
this.editor.instance.select_layer(clickedLayer.path, ctrl, shift);

View file

@ -28,7 +28,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeImage'" :style="'node'" />
<IconLabel :icon="'NodeImage'" :iconStyle="'Node'" />
<TextLabel>Image</TextLabel>
</div>
</div>
@ -42,7 +42,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeImage'" :style="'node'" />
<IconLabel :icon="'NodeImage'" :iconStyle="'Node'" />
<TextLabel>Mask</TextLabel>
</div>
<div class="arguments">
@ -69,7 +69,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeTransform'" :style="'node'" />
<IconLabel :icon="'NodeTransform'" :iconStyle="'Node'" />
<TextLabel>Transform</TextLabel>
</div>
</div>
@ -83,7 +83,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeMotionBlur'" :style="'node'" />
<IconLabel :icon="'NodeMotionBlur'" :iconStyle="'Node'" />
<TextLabel>Motion Blur</TextLabel>
</div>
<div class="arguments">
@ -110,7 +110,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeShape'" :style="'node'" />
<IconLabel :icon="'NodeShape'" :iconStyle="'Node'" />
<TextLabel>Shape</TextLabel>
</div>
</div>
@ -124,7 +124,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeBrushwork'" :style="'node'" />
<IconLabel :icon="'NodeBrushwork'" :iconStyle="'Node'" />
<TextLabel>Brushwork</TextLabel>
</div>
</div>
@ -138,7 +138,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeBlur'" :style="'node'" />
<IconLabel :icon="'NodeBlur'" :iconStyle="'Node'" />
<TextLabel>Blur</TextLabel>
</div>
</div>
@ -152,7 +152,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeGradient'" :style="'node'" />
<IconLabel :icon="'NodeGradient'" :iconStyle="'Node'" />
<TextLabel>Gradient</TextLabel>
</div>
</div>

View file

@ -4,35 +4,41 @@
<template>
<div :class="`widget-${direction}`">
<template v-for="(component, index) in widgets" :key="index">
<CheckboxInput v-if="component.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" />
<ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" v-model:open="open" @update:value="(value: string) => updateLayout(component.widget_id, value)" />
<DropdownInput v-if="component.kind === 'DropdownInput'" v-bind="component.props" v-model:open="open" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
<FontInput
v-if="component.kind === 'FontInput'"
<CheckboxInput v-if="component.props.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widgetId, value)" />
<ColorInput v-if="component.props.kind === 'ColorInput'" v-bind="component.props" v-model:open="open" @update:value="(value: string) => updateLayout(component.widgetId, value)" />
<DropdownInput
v-if="component.props.kind === 'DropdownInput'"
v-bind="component.props"
v-model:open="open"
@changeFont="(value: { name: string, style: string, file: string }) => updateLayout(component.widget_id, value)"
@update:selectedIndex="(value: number) => updateLayout(component.widgetId, value)"
/>
<IconButton v-if="component.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(component.widget_id, null)" />
<IconLabel v-if="component.kind === 'IconLabel'" v-bind="component.props" />
<NumberInput
v-if="component.kind === 'NumberInput'"
<FontInput
v-if="component.props.kind === 'FontInput'"
v-bind="component.props"
@update:value="(value: number) => updateLayout(component.widget_id, value)"
:incrementCallbackIncrease="() => updateLayout(component.widget_id, 'Increment')"
:incrementCallbackDecrease="() => updateLayout(component.widget_id, 'Decrement')"
v-model:open="open"
@changeFont="(value: { name: string, style: string, file: string }) => updateLayout(component.widgetId, value)"
/>
<OptionalInput v-if="component.kind === 'OptionalInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" />
<PopoverButton v-if="component.kind === 'PopoverButton'">
<h3>{{ component.props.title }}</h3>
<IconButton v-if="component.props.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(component.widgetId, null)" />
<IconLabel v-if="component.props.kind === 'IconLabel'" v-bind="component.props" />
<NumberInput
v-if="component.props.kind === 'NumberInput'"
v-bind="component.props"
@update:value="(value: number) => updateLayout(component.widgetId, value)"
:incrementCallbackIncrease="() => updateLayout(component.widgetId, 'Increment')"
:incrementCallbackDecrease="() => updateLayout(component.widgetId, 'Decrement')"
/>
<OptionalInput v-if="component.props.kind === 'OptionalInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widgetId, value)" />
<PopoverButton v-if="component.props.kind === 'PopoverButton'" v-bind="component.props">
<h3>{{ component.props.header }}</h3>
<p>{{ component.props.text }}</p>
</PopoverButton>
<RadioInput v-if="component.kind === 'RadioInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
<Separator v-if="component.kind === 'Separator'" v-bind="component.props" />
<TextAreaInput v-if="component.kind === 'TextAreaInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widget_id, value)" />
<TextButton v-if="component.kind === 'TextButton'" v-bind="component.props" :action="() => updateLayout(component.widget_id, null)" />
<TextInput v-if="component.kind === 'TextInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widget_id, value)" />
<TextLabel v-if="component.kind === 'TextLabel'" v-bind="withoutValue(component.props)">{{ component.props.value }}</TextLabel>
<RadioInput v-if="component.props.kind === 'RadioInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(component.widgetId, value)" />
<Separator v-if="component.props.kind === 'Separator'" v-bind="component.props" />
<SwatchPairInput v-if="component.props.kind === 'SwatchPairInput'" v-bind="component.props" />
<TextAreaInput v-if="component.props.kind === 'TextAreaInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widgetId, value)" />
<TextButton v-if="component.props.kind === 'TextButton'" v-bind="component.props" :action="() => updateLayout(component.widgetId, null)" />
<TextInput v-if="component.props.kind === 'TextInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widgetId, value)" />
<TextLabel v-if="component.props.kind === 'TextLabel'" v-bind="withoutValue(component.props)">{{ component.props.value }}</TextLabel>
</template>
</div>
</template>
@ -84,6 +90,7 @@ import FontInput from "@/components/widgets/inputs/FontInput.vue";
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
import RadioInput from "@/components/widgets/inputs/RadioInput.vue";
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.vue";
import TextAreaInput from "@/components/widgets/inputs/TextAreaInput.vue";
import TextInput from "@/components/widgets/inputs/TextInput.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@ -123,21 +130,22 @@ export default defineComponent({
},
},
components: {
Separator,
PopoverButton,
TextButton,
CheckboxInput,
NumberInput,
TextInput,
IconButton,
OptionalInput,
RadioInput,
DropdownInput,
TextLabel,
IconLabel,
ColorInput,
DropdownInput,
FontInput,
IconButton,
IconLabel,
NumberInput,
OptionalInput,
PopoverButton,
RadioInput,
Separator,
SwatchPairInput,
TextAreaInput,
TextButton,
TextInput,
TextLabel,
},
});
</script>

View file

@ -1,5 +1,5 @@
<template>
<button :class="['icon-button', `size-${size}`, active && 'active']" @click="(e: MouseEvent) => action(e)">
<button :class="['icon-button', `size-${size}`, active && 'active']" @click="(e: MouseEvent) => action(e)" :title="tooltip">
<IconLabel :icon="icon" />
</button>
</template>
@ -69,11 +69,13 @@ import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({
props: {
action: { type: Function as PropType<(e?: MouseEvent) => void>, required: true },
icon: { type: String as PropType<IconName>, required: true },
size: { type: Number as PropType<IconSize>, required: true },
active: { type: Boolean as PropType<boolean>, default: false },
gapAfter: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
// Callbacks
action: { type: Function as PropType<(e?: MouseEvent) => void>, required: true },
},
components: { IconLabel },
});

View file

@ -62,8 +62,10 @@ export default defineComponent({
LayoutRow,
},
props: {
action: { type: Function as PropType<() => void>, required: false },
icon: { type: String as PropType<IconName>, default: "DropdownArrow" },
// Callbacks
action: { type: Function as PropType<() => void>, required: false },
},
data() {
return {

View file

@ -1,16 +1,17 @@
// TODO: Try and get rid of the need for this file
export interface TextButtonWidget {
kind: "TextButton";
tooltip?: string;
message?: string | object;
callback?: () => void;
props: {
// `action` is used via `IconButtonWidget.callback`
kind: "TextButton";
label: string;
emphasized?: boolean;
disabled?: boolean;
minWidth?: number;
gapAfter?: boolean;
disabled?: boolean;
// Callbacks
// `action` is used via `IconButtonWidget.callback`
};
}

View file

@ -62,12 +62,13 @@ import TextLabel from "@/components/widgets/labels/TextLabel.vue";
export default defineComponent({
props: {
action: { type: Function as PropType<(e: MouseEvent) => void>, required: true },
label: { type: String as PropType<string>, required: true },
emphasized: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
gapAfter: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
// Callbacks
action: { type: Function as PropType<(e: MouseEvent) => void>, required: true },
},
components: { TextLabel },
});

View file

@ -1,7 +1,7 @@
<template>
<LayoutRow class="checkbox-input">
<input type="checkbox" :id="`checkbox-input-${id}`" :checked="checked" @change="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)" />
<label :for="`checkbox-input-${id}`" :tabindex="disableTabIndex ? -1 : 0" @keydown.enter="(e) => ((e.target as HTMLElement).previousSibling as HTMLInputElement).click()">
<label :for="`checkbox-input-${id}`" tabindex="0" @keydown.enter="(e) => ((e.target as HTMLElement).previousSibling as HTMLInputElement).click()" :title="tooltip">
<LayoutRow class="checkbox-box">
<IconLabel :icon="icon" />
</LayoutRow>
@ -66,6 +66,11 @@ import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({
emits: ["update:checked"],
props: {
checked: { type: Boolean as PropType<boolean>, default: false },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {
id: `${Math.random()}`.substring(2),
@ -76,11 +81,6 @@ export default defineComponent({
return this.checked;
},
},
props: {
checked: { type: Boolean as PropType<boolean>, default: false },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
disableTabIndex: { type: Boolean as PropType<boolean>, default: false },
},
components: {
IconLabel,
LayoutRow,

View file

@ -1,6 +1,6 @@
<template>
<LayoutRow class="color-input">
<OptionalInput v-if="canSetTransparent" :icon="'CloseX'" :checked="!!value" @update:checked="(val) => updateEnabled(val)"></OptionalInput>
<LayoutRow class="color-input" :title="tooltip">
<OptionalInput v-if="!noTransparency" :icon="'CloseX'" :checked="Boolean(value)" @update:checked="(val) => updateEnabled(val)"></OptionalInput>
<TextInput :value="displayValue" :label="label" :disabled="disabled || !value" @commitText="(value: string) => textInputUpdated(value)" :center="true" />
<Separator :type="'Related'" />
<LayoutRow class="swatch">
@ -82,11 +82,15 @@ import Separator from "@/components/widgets/labels/Separator.vue";
export default defineComponent({
emits: ["update:value", "update:open"],
props: {
value: { type: String as PropType<string | undefined>, required: true },
open: { type: Boolean as PropType<boolean>, required: true },
value: { type: String as PropType<string | undefined>, required: false },
label: { type: String as PropType<string>, required: false },
canSetTransparent: { type: Boolean as PropType<boolean>, required: false, default: true },
noTransparency: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
// Bound through `v-model`
// TODO: See if this should be made to follow the pattern of DropdownInput.vue so this could be removed
open: { type: Boolean as PropType<boolean>, required: true },
},
data() {
return {

View file

@ -99,7 +99,9 @@
<script lang="ts">
import { defineComponent, PropType, toRaw } from "vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/floating-menus/MenuList.vue";
import { MenuListEntry, SectionsOfMenuListEntries } from "@/wasm-communication/messages";
import MenuList from "@/components/floating-menus/MenuList.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";

View file

@ -71,9 +71,10 @@
<script lang="ts">
import { defineComponent, nextTick, PropType } from "vue";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
import MenuList, { MenuListEntry } from "@/components/floating-menus/MenuList.vue";
import { MenuListEntry } from "@/wasm-communication/messages";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
import MenuList from "@/components/floating-menus/MenuList.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
@ -84,8 +85,8 @@ export default defineComponent({
props: {
fontFamily: { type: String as PropType<string>, required: true },
fontStyle: { type: String as PropType<string>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
isStyle: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
@ -111,6 +112,7 @@ export default defineComponent({
},
async setOpen() {
this.open = true;
// Scroll to the active entry (the scroller div does not yet exist so we must wait for vue to render)
await nextTick();
if (this.activeEntry) {

View file

@ -72,9 +72,9 @@
<script lang="ts">
import { defineComponent } from "vue";
import { MenuEntry, UpdateMenuBarLayout } from "@/wasm-communication/messages";
import { MenuEntry, UpdateMenuBarLayout, MenuListEntry } from "@/wasm-communication/messages";
import MenuList, { MenuListEntry } from "@/components/floating-menus/MenuList.vue";
import MenuList from "@/components/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
const LOCK_REQUIRING_SHORTCUTS = [
@ -100,7 +100,7 @@ export default defineComponent({
group.map((entry) => ({
...entry,
children: entry.children ? menuEntryToFrontendMenuEntry(entry.children) : undefined,
action: (): void => this.editor.instance.update_layout(updateMenuBarLayout.layout_target, entry.action.widget_id, undefined),
action: (): void => this.editor.instance.update_layout(updateMenuBarLayout.layout_target, entry.action.widgetId, undefined),
shortcutRequiresLock: entry.shortcut ? shortcutRequiresLock(entry.shortcut) : undefined,
}))
);

View file

@ -87,27 +87,30 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IncrementBehavior } from "@/wasm-communication/messages";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
type IncrementDirection = "Decrease" | "Increase";
export type IncrementDirection = "Decrease" | "Increase";
export default defineComponent({
emits: ["update:value"],
props: {
label: { type: String as PropType<string>, required: false },
value: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
min: { type: Number as PropType<number>, required: false },
max: { type: Number as PropType<number>, required: false },
incrementBehavior: { type: String as PropType<IncrementBehavior>, default: "Add" },
incrementFactor: { type: Number as PropType<number>, default: 1 },
incrementCallbackIncrease: { type: Function as PropType<() => void>, required: false },
incrementCallbackDecrease: { type: Function as PropType<() => void>, required: false },
isInteger: { type: Boolean as PropType<boolean>, default: false },
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 },
unit: { type: String as PropType<string>, default: "" },
unitIsHiddenWhenEditing: { type: Boolean as PropType<boolean>, default: true },
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 },
label: { type: String as PropType<string>, required: false },
incrementBehavior: { type: String as PropType<IncrementBehavior>, default: "Add" },
incrementFactor: { type: Number as PropType<number>, default: 1 },
disabled: { type: Boolean as PropType<boolean>, default: false },
// Callbacks
incrementCallbackIncrease: { type: Function as PropType<() => void>, required: false },
incrementCallbackDecrease: { type: Function as PropType<() => void>, required: false },
},
data() {
return {

View file

@ -1,6 +1,6 @@
<template>
<LayoutRow class="optional-input">
<CheckboxInput :checked="checked" @input="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)" :icon="icon" />
<CheckboxInput :checked="checked" @input="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)" :icon="icon" :tooltip="tooltip" />
</LayoutRow>
</template>
@ -47,6 +47,7 @@ export default defineComponent({
props: {
checked: { type: Boolean as PropType<boolean>, required: true },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
components: {
CheckboxInput,

View file

@ -64,22 +64,12 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { IconName } from "@/utility-functions/icons";
import { RadioEntries, RadioEntryData } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
export interface RadioEntryData {
value?: string;
label?: string;
icon?: IconName;
tooltip?: string;
action?: () => void;
}
export type RadioEntries = RadioEntryData[];
export default defineComponent({
emits: ["update:selectedIndex"],
props: {

View file

@ -1,15 +1,15 @@
<template>
<LayoutCol class="swatch-pair">
<LayoutRow class="secondary swatch">
<button @click="() => clickSecondarySwatch()" ref="secondaryButton" data-hover-menu-spawner></button>
<button @click="() => clickSecondarySwatch()" :style="`--swatch-color: ${secondary.toRgbaCSS()}`" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="secondaryOpen">
<ColorPicker @update:color="(color: RGBA) => secondaryColorChanged(color)" :color="secondaryColor" />
<ColorPicker @update:color="(color: RGBA) => secondaryColorChanged(color)" :color="secondary.toRgba()" />
</FloatingMenu>
</LayoutRow>
<LayoutRow class="primary swatch">
<button @click="() => clickPrimarySwatch()" ref="primaryButton" data-hover-menu-spawner></button>
<button @click="() => clickPrimarySwatch()" :style="`--swatch-color: ${primary.toRgbaCSS()}`" data-hover-menu-spawner></button>
<FloatingMenu :type="'Popover'" :direction="'Right'" v-model:open="primaryOpen">
<ColorPicker @update:color="(color: RGBA) => primaryColorChanged(color)" :color="primaryColor" />
<ColorPicker @update:color="(color: RGBA) => primaryColorChanged(color)" :color="primary.toRgba()" />
</FloatingMenu>
</LayoutRow>
</LayoutCol>
@ -66,10 +66,10 @@
</style>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, PropType } from "vue";
import { rgbaToDecimalRgba } from "@/utility-functions/color";
import { type RGBA, UpdateWorkingColors } from "@/wasm-communication/messages";
import { type RGBA, Color } from "@/wasm-communication/messages";
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
@ -84,12 +84,14 @@ export default defineComponent({
LayoutRow,
LayoutCol,
},
props: {
primary: { type: Object as PropType<Color>, required: true },
secondary: { type: Object as PropType<Color>, required: true },
},
data() {
return {
primaryOpen: false,
secondaryOpen: false,
primaryColor: { r: 0, g: 0, b: 0, a: 1 } as RGBA,
secondaryColor: { r: 255, g: 255, b: 255, a: 1 } as RGBA,
};
},
methods: {
@ -102,44 +104,13 @@ export default defineComponent({
this.secondaryOpen = true;
},
primaryColorChanged(color: RGBA) {
this.primaryColor = color;
this.updatePrimaryColor();
const newColor = rgbaToDecimalRgba(color);
this.editor.instance.update_primary_color(newColor.r, newColor.g, newColor.b, newColor.a);
},
secondaryColorChanged(color: RGBA) {
this.secondaryColor = color;
this.updateSecondaryColor();
const newColor = rgbaToDecimalRgba(color);
this.editor.instance.update_secondary_color(newColor.r, newColor.g, newColor.b, newColor.a);
},
async updatePrimaryColor() {
let color = this.primaryColor;
const button = this.$refs.primaryButton as HTMLButtonElement;
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
color = rgbaToDecimalRgba(this.primaryColor);
this.editor.instance.update_primary_color(color.r, color.g, color.b, color.a);
},
async updateSecondaryColor() {
let color = this.secondaryColor;
const button = this.$refs.secondaryButton as HTMLButtonElement;
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
color = rgbaToDecimalRgba(this.secondaryColor);
this.editor.instance.update_secondary_color(color.r, color.g, color.b, color.a);
},
},
mounted() {
this.editor.subscriptions.subscribeJsMessage(UpdateWorkingColors, (updateWorkingColors) => {
this.primaryColor = updateWorkingColors.primary.toRgba();
this.secondaryColor = updateWorkingColors.secondary.toRgba();
const primaryButton = this.$refs.primaryButton as HTMLButtonElement;
primaryButton.style.setProperty("--swatch-color", updateWorkingColors.primary.toRgbaCSS());
const secondaryButton = this.$refs.secondaryButton as HTMLButtonElement;
secondaryButton.style.setProperty("--swatch-color", updateWorkingColors.secondary.toRgbaCSS());
});
this.updatePrimaryColor();
this.updateSecondaryColor();
},
});
</script>

View file

@ -1,5 +1,5 @@
<template>
<LayoutRow :class="['icon-label', iconSize, iconStyle]">
<LayoutRow :class="['icon-label', iconSizeClass, iconStyleClass]">
<component :is="icon" />
</LayoutRow>
</template>
@ -42,16 +42,15 @@ import LayoutRow from "@/components/layout/LayoutRow.vue";
export default defineComponent({
props: {
icon: { type: String as PropType<IconName>, required: true },
gapAfter: { type: Boolean as PropType<boolean>, default: false },
style: { type: String as PropType<IconStyle>, default: "" },
iconStyle: { type: String as PropType<IconStyle | undefined>, required: false },
},
computed: {
iconSize(): string {
iconSizeClass(): string {
return `size-${icons[this.icon].size}`;
},
iconStyle(): string {
if (!this.style) return "";
return `${this.style}-style`;
iconStyleClass(): string {
if (!this.iconStyle || this.iconStyle === "Normal") return "";
return `${this.iconStyle.toLowerCase()}-style`;
},
},
components: {

View file

@ -75,8 +75,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
export type SeparatorDirection = "Horizontal" | "Vertical";
export type SeparatorType = "Related" | "Unrelated" | "Section" | "List";
import { SeparatorDirection, SeparatorType } from "@/wasm-communication/messages";
export default defineComponent({
props: {

View file

@ -21,7 +21,36 @@
</PopoverButton>
</LayoutRow>
<LayoutCol class="panel-body">
<component :is="panelType" />
<component :is="panelType" v-if="panelType" />
<LayoutCol class="empty-panel" v-else>
<LayoutCol class="content">
<LayoutRow class="logotype">
<IconLabel :icon="'GraphiteLogotypeSolid'" />
</LayoutRow>
<LayoutRow class="actions">
<LayoutCol>
<IconButton :action="() => newDocument()" :icon="'File'" :size="24" />
<IconButton :action="() => openDocument()" :icon="'Folder'" :size="24" />
</LayoutCol>
<LayoutCol>
<Separator :type="'Related'" />
<Separator :type="'Related'" />
</LayoutCol>
<LayoutCol>
<TextLabel>New Document:</TextLabel>
<TextLabel>Open Document:</TextLabel>
</LayoutCol>
<LayoutCol>
<Separator :type="'Unrelated'" />
<Separator :type="'Unrelated'" />
</LayoutCol>
<LayoutCol>
<UserInputLabel :inputKeys="[['KeyControl', 'KeyN']]" />
<UserInputLabel :inputKeys="[['KeyControl', 'KeyO']]" />
</LayoutCol>
</LayoutRow>
</LayoutCol>
</LayoutCol>
</LayoutCol>
</LayoutCol>
</template>
@ -141,6 +170,45 @@
flex: 1 1 100%;
flex-direction: column;
min-height: 0;
.empty-panel {
background: var(--color-2-mildblack);
margin: 4px;
border-radius: 2px;
justify-content: center;
.content {
flex: 0 0 auto;
align-items: center;
.logotype {
margin-bottom: 40px;
svg {
width: auto;
height: 120px;
}
}
.actions {
> div {
gap: 8px;
> * {
height: 24px;
}
.text-label {
line-height: 24px;
}
.user-input-label {
margin: 0;
}
}
}
}
}
}
}
</style>
@ -156,6 +224,10 @@ import NodeGraph from "@/components/panels/NodeGraph.vue";
import Properties from "@/components/panels/Properties.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import Separator from "@/components/widgets/labels/Separator.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
const panelComponents = {
Document,
@ -168,18 +240,31 @@ const panelComponents = {
type PanelTypes = keyof typeof panelComponents;
export default defineComponent({
inject: ["editor"],
props: {
tabMinWidths: { type: Boolean as PropType<boolean>, default: false },
tabCloseButtons: { type: Boolean as PropType<boolean>, default: false },
tabLabels: { type: Array as PropType<string[]>, required: true },
tabActiveIndex: { type: Number as PropType<number>, required: true },
panelType: { type: String as PropType<PanelTypes>, required: true },
panelType: { type: String as PropType<PanelTypes>, required: false },
clickAction: { type: Function as PropType<(index: number) => void>, required: false },
closeAction: { type: Function as PropType<(index: number) => void>, required: false },
},
methods: {
newDocument() {
this.editor.instance.new_document_dialog();
},
openDocument() {
this.editor.instance.open_file_upload();
},
},
components: {
LayoutCol,
LayoutRow,
IconLabel,
TextLabel,
UserInputLabel,
Separator,
...panelComponents,
},
});

View file

@ -4,7 +4,7 @@
<LayoutCol class="workspace-grid-subdivision">
<LayoutRow class="workspace-grid-subdivision">
<Panel
:panelType="'Document'"
:panelType="portfolio.state.documents.length > 0 ? 'Document' : undefined"
:tabCloseButtons="true"
:tabMinWidths="true"
:tabLabels="portfolio.state.documents.map((doc) => doc.displayName)"
@ -64,7 +64,7 @@
</style>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, nextTick } from "vue";
import DialogModal from "@/components/floating-menus/DialogModal.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
@ -117,7 +117,7 @@ export default defineComponent({
nextSibling.style.flexGrow = (nextSiblingSize + mouseDelta).toString();
previousSibling.style.flexGrow = (previousSiblingSize - mouseDelta).toString();
window.dispatchEvent(new CustomEvent("resize", { detail: {} }));
window.dispatchEvent(new CustomEvent("resize"));
}
function cleanup(event: PointerEvent): void {
@ -134,12 +134,12 @@ export default defineComponent({
},
},
watch: {
activeDocumentIndex(newIndex: number) {
this.$nextTick(() => {
const documentsPanel = this.$refs.documentsPanel as typeof Panel;
const newActiveTab = documentsPanel.$el.querySelectorAll("[data-tab-bar] [data-tab]")[newIndex];
newActiveTab.scrollIntoView();
});
async activeDocumentIndex(newIndex: number) {
await nextTick();
const documentsPanel = this.$refs.documentsPanel as typeof Panel;
const newActiveTab = documentsPanel.$el.querySelectorAll("[data-tab-bar] [data-tab]")[newIndex];
newActiveTab.scrollIntoView();
},
},
});

View file

@ -0,0 +1,18 @@
import { Editor } from "@/wasm-communication/editor";
import { UpdateImageData } from "@/wasm-communication/messages";
export function createBlobManager(editor: Editor): void {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
updateImageData.image_data.forEach(async (element) => {
// Using updateImageData.image_data.buffer returns undefined for some reason?
const blob = new Blob([new Uint8Array(element.image_data.values()).buffer], { type: element.mime });
const url = URL.createObjectURL(blob);
const image = await createImageBitmap(blob);
editor.instance.set_image_blob_url(element.path, url, image.width, image.height);
});
});
}

View file

@ -3,7 +3,7 @@ import { DialogState } from "@/state-providers/dialog";
import { IconName } from "@/utility-functions/icons";
import { stripIndents } from "@/utility-functions/strip-indents";
import { Editor } from "@/wasm-communication/editor";
import { DisplayDialogPanic, WidgetLayout } from "@/wasm-communication/messages";
import { DisplayDialogPanic, Widget, WidgetLayout } from "@/wasm-communication/messages";
export function createPanicManager(editor: Editor, dialogState: DialogState): void {
// Code panic dialog and console error
@ -17,53 +17,32 @@ export function createPanicManager(editor: Editor, dialogState: DialogState): vo
// eslint-disable-next-line no-console
console.error(panicDetails);
const panicDialog = preparePanicDialog(displayDialogPanic.title, displayDialogPanic.description, panicDetails);
const panicDialog = preparePanicDialog(displayDialogPanic.header, displayDialogPanic.description, panicDetails);
dialogState.createPanicDialog(...panicDialog);
});
}
function preparePanicDialog(title: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
function preparePanicDialog(header: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
const widgets: WidgetLayout = {
layout: [
{
rowWidgets: [
{
kind: "TextLabel",
props: { value: title, bold: true },
// eslint-disable-next-line camelcase
widget_id: 0n,
},
],
},
{
rowWidgets: [
{
kind: "TextLabel",
props: { value: details, multiline: true },
// eslint-disable-next-line camelcase
widget_id: 0n,
},
],
},
{ rowWidgets: [new Widget({ kind: "TextLabel", value: header, bold: true, italic: false, tableAlign: false, multiline: false }, 0n)] },
{ rowWidgets: [new Widget({ kind: "TextLabel", value: details, bold: false, italic: false, tableAlign: false, multiline: true }, 1n)] },
],
// eslint-disable-next-line camelcase
layout_target: null,
};
const reloadButton: TextButtonWidget = {
kind: "TextButton",
callback: async () => window.location.reload(),
props: { label: "Reload", emphasized: true, minWidth: 96 },
props: { kind: "TextButton", label: "Reload", emphasized: true, minWidth: 96 },
};
const copyErrorLogButton: TextButtonWidget = {
kind: "TextButton",
callback: async () => navigator.clipboard.writeText(panicDetails),
props: { label: "Copy Error Log", emphasized: false, minWidth: 96 },
props: { kind: "TextButton", label: "Copy Error Log", emphasized: false, minWidth: 96 },
};
const reportOnGithubButton: TextButtonWidget = {
kind: "TextButton",
callback: async () => window.open(githubUrl(panicDetails), "_blank"),
props: { label: "Report Bug", emphasized: false, minWidth: 96 },
props: { kind: "TextButton", label: "Report Bug", emphasized: false, minWidth: 96 },
};
const jsCallbackBasedButtons = [reloadButton, copyErrorLogButton, reportOnGithubButton];

View file

@ -20,7 +20,7 @@ import App from "@/App.vue";
}
</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 later.</p>
<p>Please upgrade to a modern web browser such as the latest Firefox, Chrome, Edge, or Safari version 15 or newer.</p>
<p>(The <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt64Array#browser_compatibility" target="_blank"><code>BigInt64Array</code></a>
JavaScript API must be supported by the browser for Graphite to function.)</p>
`;

View file

@ -1,7 +1,9 @@
import { reactive, readonly } from "vue";
import { Editor } from "@/wasm-communication/editor";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createFullscreenState() {
export function createFullscreenState(_: Editor) {
const state = reactive({
windowFullscreen: false,
keyboardLocked: false,

View file

@ -0,0 +1,130 @@
import { nextTick, reactive, readonly } from "vue";
import { Editor } from "@/wasm-communication/editor";
import {
DisplayEditableTextbox,
DisplayRemoveEditableTextbox,
TriggerRefreshBoundsOfViewports,
TriggerTextCommit,
TriggerViewportResize,
UpdateDocumentArtboards,
UpdateDocumentArtwork,
UpdateDocumentBarLayout,
UpdateDocumentModeLayout,
UpdateDocumentOverlays,
UpdateDocumentRulers,
UpdateDocumentScrollbars,
UpdateMouseCursor,
UpdateToolOptionsLayout,
UpdateToolShelfLayout,
UpdateWorkingColorsLayout,
} from "@/wasm-communication/messages";
import DocumentComponent from "@/components/panels/Document.vue";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createPanelsState(editor: Editor) {
const state = reactive({
documentPanel: DocumentComponent,
});
// We use `any` instead of `typeof DocumentComponent` as a workaround for the fact that calling this function with the `this` argument from within `Document.vue` isn't a compatible type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function registerPanel(type: string, panelComponent: any): void {
state.documentPanel = panelComponent;
}
function subscribeDocumentPanel(): void {
// Update rendered SVGs
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (updateDocumentArtwork) => {
await nextTick();
state.documentPanel.updateDocumentArtwork(updateDocumentArtwork.svg);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, async (updateDocumentOverlays) => {
await nextTick();
state.documentPanel.updateDocumentOverlays(updateDocumentOverlays.svg);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, async (updateDocumentArtboards) => {
await nextTick();
state.documentPanel.updateDocumentArtboards(updateDocumentArtboards.svg);
});
// Update scrollbars and rulers
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (updateDocumentScrollbars) => {
await nextTick();
const { position, size, multiplier } = updateDocumentScrollbars;
state.documentPanel.updateDocumentScrollbars(position, size, multiplier);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (updateDocumentRulers) => {
await nextTick();
const { origin, spacing, interval } = updateDocumentRulers;
state.documentPanel.updateDocumentRulers(origin, spacing, interval);
});
// Update mouse cursor icon
editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, async (updateMouseCursor) => {
await nextTick();
const { cursor } = updateMouseCursor;
state.documentPanel.updateMouseCursor(cursor);
});
// Text entry
editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => {
await nextTick();
state.documentPanel.triggerTextCommit();
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (displayEditableTextbox) => {
await nextTick();
state.documentPanel.displayEditableTextbox(displayEditableTextbox);
});
editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => {
await nextTick();
state.documentPanel.displayRemoveEditableTextbox();
});
// Update layouts
editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, async (updateDocumentModeLayout) => {
await nextTick();
state.documentPanel.updateDocumentModeLayout(updateDocumentModeLayout);
});
editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (updateToolOptionsLayout) => {
await nextTick();
state.documentPanel.updateToolOptionsLayout(updateToolOptionsLayout);
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (updateDocumentBarLayout) => {
await nextTick();
state.documentPanel.updateDocumentBarLayout(updateDocumentBarLayout);
});
editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (updateToolShelfLayout) => {
await nextTick();
state.documentPanel.updateToolShelfLayout(updateToolShelfLayout);
});
editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (updateWorkingColorsLayout) => {
await nextTick();
state.documentPanel.updateWorkingColorsLayout(updateWorkingColorsLayout);
});
// Resize elements to render the new viewport size
editor.subscriptions.subscribeJsMessage(TriggerViewportResize, async () => {
await nextTick();
state.documentPanel.viewportResize();
});
editor.subscriptions.subscribeJsMessage(TriggerRefreshBoundsOfViewports, async () => {
// Wait to display the unpopulated document panel (missing: tools, options bar content, scrollbar positioning, and canvas)
await nextTick();
// Wait to display the populated document panel
await nextTick();
// Request a resize event so the viewport gets measured now that the canvas is populated and positioned correctly
window.dispatchEvent(new CustomEvent("resize"));
});
}
subscribeDocumentPanel();
return {
state: readonly(state) as typeof state,
registerPanel,
};
}
export type PanelsState = ReturnType<typeof createPanelsState>;

View file

@ -1,5 +1,12 @@
/* eslint-disable import/first */
// Graphics
import GraphiteLogotypeSolid from "@/../assets/graphics/graphite-logotype-solid.svg";
const GRAPHICS = {
GraphiteLogotypeSolid: { component: GraphiteLogotypeSolid, size: null },
} as const;
// 12px Solid
import Checkmark from "@/../assets/icon-12px-solid/checkmark.svg";
import CloseX from "@/../assets/icon-12px-solid/close-x.svg";
@ -83,6 +90,7 @@ import EyeVisible from "@/../assets/icon-16px-solid/eye-visible.svg";
import File from "@/../assets/icon-16px-solid/file.svg";
import FlipHorizontal from "@/../assets/icon-16px-solid/flip-horizontal.svg";
import FlipVertical from "@/../assets/icon-16px-solid/flip-vertical.svg";
import Folder from "@/../assets/icon-16px-solid/folder.svg";
import GraphiteLogo from "@/../assets/icon-16px-solid/graphite-logo.svg";
import NodeArtboard from "@/../assets/icon-16px-solid/node-artboard.svg";
import NodeBlur from "@/../assets/icon-16px-solid/node-blur.svg";
@ -130,6 +138,7 @@ const SOLID_16PX = {
File: { component: File, size: 16 },
FlipHorizontal: { component: FlipHorizontal, size: 16 },
FlipVertical: { component: FlipVertical, size: 16 },
Folder: { component: Folder, size: 16 },
GraphiteLogo: { component: GraphiteLogo, size: 16 },
NodeArtboard: { component: NodeArtboard, size: 16 },
NodeBlur: { component: NodeBlur, size: 16 },
@ -232,6 +241,7 @@ const TWO_TONE_24PX = {
// All icons
const ICON_LIST = {
...GRAPHICS,
...SOLID_12PX,
...SOLID_16PX,
...TWO_TONE_16PX,
@ -243,8 +253,8 @@ export const icons: IconDefinitionType<typeof ICON_LIST> = ICON_LIST;
export const iconComponents = Object.fromEntries(Object.entries(icons).map(([name, data]) => [name, data.component]));
export type IconName = keyof typeof icons;
export type IconSize = 12 | 16 | 24 | 32;
export type IconStyle = "node" | "";
export type IconSize = null | 12 | 16 | 24 | 32;
export type IconStyle = "Normal" | "Node";
// The following helper type declarations allow us to avoid manually maintaining the `IconName` type declaration as a string union paralleling the keys of the
// icon definitions. It lets TypeScript do that for us. Our goal is to define the big key-value pair of icons by constraining its values, but inferring its keys.

View file

@ -1,9 +1,9 @@
/* eslint-disable camelcase */
/* eslint-disable max-classes-per-file */
import { Transform, Type } from "class-transformer";
import { Transform, Type, plainToClass } from "class-transformer";
import { IconName } from "@/utility-functions/icons";
import { IconName, IconSize, IconStyle } from "@/utility-functions/icons";
import type { WasmEditorInstance, WasmRawInstance } from "@/wasm-communication/editor";
export class JsMessage {
@ -12,7 +12,7 @@ export class JsMessage {
}
// ============================================================================
// Add additional classes to replicate Rust's `FrontendMessage`s and data structures below.
// Add additional classes below to replicate Rust's `FrontendMessage`s and data structures.
//
// Remember to add each message to the `messageConstructors` export at the bottom of the file.
//
@ -20,6 +20,15 @@ export class JsMessage {
// for details about how to transform the JSON from wasm-bindgen into classes.
// ============================================================================
export class UpdateNodeGraphVisibility extends JsMessage {
readonly visible!: boolean;
}
export class UpdateOpenDocumentsList extends JsMessage {
@Type(() => FrontendDocumentDetails)
readonly open_documents!: FrontendDocumentDetails[];
}
// Allows the auto save system to use a string for the id rather than a BigInt.
// IndexedDb does not allow for BigInts as primary keys.
// TypeScript does not allow subclasses to change the type of class variables in subclasses.
@ -40,13 +49,24 @@ export class FrontendDocumentDetails extends DocumentDetails {
readonly id!: bigint;
}
export class UpdateNodeGraphVisibility extends JsMessage {
readonly visible!: boolean;
export class TriggerIndexedDbWriteDocument extends JsMessage {
document!: string;
@Type(() => IndexedDbDocumentDetails)
details!: IndexedDbDocumentDetails;
version!: string;
}
export class UpdateOpenDocumentsList extends JsMessage {
@Type(() => FrontendDocumentDetails)
readonly open_documents!: FrontendDocumentDetails[];
export class IndexedDbDocumentDetails extends DocumentDetails {
@Transform(({ value }: { value: bigint }) => value.toString())
id!: string;
}
export class TriggerIndexedDbRemoveDocument extends JsMessage {
// Use a string since IndexedDB can not use BigInts for keys
@Transform(({ value }: { value: bigint }) => value.toString())
document_id!: string;
}
export class UpdateInputHints extends JsMessage {
@ -86,7 +106,7 @@ export type HSVA = {
a: number;
};
const To255Scale = Transform(({ value }) => value * 255);
const To255Scale = Transform(({ value }: { value: number }) => value * 255);
export class Color {
@To255Scale
readonly red!: number;
@ -109,14 +129,6 @@ export class Color {
}
}
export class UpdateWorkingColors extends JsMessage {
@Type(() => Color)
readonly primary!: Color;
@Type(() => Color)
readonly secondary!: Color;
}
export class UpdateActiveDocument extends JsMessage {
readonly document_id!: bigint;
}
@ -124,7 +136,7 @@ export class UpdateActiveDocument extends JsMessage {
export class DisplayDialogPanic extends JsMessage {
readonly panic_info!: string;
readonly title!: string;
readonly header!: string;
readonly description!: string;
}
@ -145,48 +157,46 @@ export class UpdateDocumentArtboards extends JsMessage {
readonly svg!: string;
}
const TupleToVec2 = Transform(({ value }) => ({ x: value[0], y: value[1] }));
const TupleToVec2 = Transform(({ value }: { value: [number, number] }) => ({ x: value[0], y: value[1] }));
export type XY = { x: number; y: number };
export class UpdateDocumentScrollbars extends JsMessage {
@TupleToVec2
readonly position!: { x: number; y: number };
readonly position!: XY;
@TupleToVec2
readonly size!: { x: number; y: number };
readonly size!: XY;
@TupleToVec2
readonly multiplier!: { x: number; y: number };
readonly multiplier!: XY;
}
export class UpdateDocumentRulers extends JsMessage {
@TupleToVec2
readonly origin!: { x: number; y: number };
readonly origin!: XY;
readonly spacing!: number;
readonly interval!: number;
}
export type MouseCursorIcon = "default" | "zoom-in" | "zoom-out" | "grabbing" | "crosshair" | "text" | "ns-resize" | "ew-resize" | "nesw-resize" | "nwse-resize";
const ToCssCursorProperty = Transform(({ value }) => {
const cssNames: Record<string, MouseCursorIcon> = {
ZoomIn: "zoom-in",
ZoomOut: "zoom-out",
Grabbing: "grabbing",
Crosshair: "crosshair",
Text: "text",
NSResize: "ns-resize",
EWResize: "ew-resize",
NESWResize: "nesw-resize",
NWSEResize: "nwse-resize",
};
return cssNames[value] || "default";
});
const mouseCursorIconCSSNames = {
ZoomIn: "zoom-in",
ZoomOut: "zoom-out",
Grabbing: "grabbing",
Crosshair: "crosshair",
Text: "text",
NSResize: "ns-resize",
EWResize: "ew-resize",
NESWResize: "nesw-resize",
NWSEResize: "nwse-resize",
} as const;
export type MouseCursor = keyof typeof mouseCursorIconCSSNames;
export type MouseCursorIcon = typeof mouseCursorIconCSSNames[MouseCursor];
export class UpdateMouseCursor extends JsMessage {
@ToCssCursorProperty
@Transform(({ value }: { value: MouseCursor }) => mouseCursorIconCSSNames[value] || "default")
readonly cursor!: MouseCursorIcon;
}
@ -208,9 +218,11 @@ export class TriggerRasterDownload extends JsMessage {
readonly mime!: string;
@TupleToVec2
readonly size!: { x: number; y: number };
readonly size!: XY;
}
export class TriggerRefreshBoundsOfViewports extends JsMessage {}
export class DocumentChanged extends JsMessage {}
export class UpdateDocumentLayerTreeStructure extends JsMessage {
@ -290,6 +302,7 @@ export class DisplayEditableTextbox extends JsMessage {
}
export class UpdateImageData extends JsMessage {
@Type(() => ImageData)
readonly image_data!: ImageData[];
}
@ -307,7 +320,7 @@ export class LayerPanelEntry {
layer_type!: LayerType;
@Transform(({ value }) => new BigUint64Array(value))
@Transform(({ value }: { value: bigint[] }) => new BigUint64Array(value))
path!: BigUint64Array;
@Type(() => LayerMetadata)
@ -332,28 +345,8 @@ export class ImageData {
readonly image_data!: Uint8Array;
}
export class IndexedDbDocumentDetails extends DocumentDetails {
@Transform(({ value }: { value: bigint }) => value.toString())
id!: string;
}
export class DisplayDialogDismiss extends JsMessage {}
export class TriggerIndexedDbWriteDocument extends JsMessage {
document!: string;
@Type(() => IndexedDbDocumentDetails)
details!: IndexedDbDocumentDetails;
version!: string;
}
export class TriggerIndexedDbRemoveDocument extends JsMessage {
// Use a string since IndexedDB can not use BigInts for keys
@Transform(({ value }: { value: bigint }) => value.toString())
document_id!: string;
}
export class Font {
font_family!: string;
@ -371,15 +364,273 @@ export class TriggerVisitLink extends JsMessage {
url!: string;
}
export class TriggerTextCommit extends JsMessage {}
export class TriggerTextCopy extends JsMessage {
readonly copy_text!: string;
}
export class TriggerAboutGraphiteLocalizedCommitDate extends JsMessage {
readonly commit_date!: string;
}
export class TriggerViewportResize extends JsMessage {}
// WIDGET PROPS
export abstract class WidgetProps {
kind!: string;
}
export class CheckboxInput extends WidgetProps {
checked!: boolean;
icon!: IconName;
tooltip!: string;
}
export class ColorInput extends WidgetProps {
value!: string | undefined;
label!: string | undefined;
noTransparency!: boolean;
disabled!: boolean;
tooltip!: string;
}
export interface MenuListEntryData<Value = string> {
value?: Value;
label?: string;
icon?: IconName;
font?: URL;
shortcut?: string[];
shortcutRequiresLock?: boolean;
disabled?: boolean;
action?: () => void;
children?: SectionsOfMenuListEntries;
}
export type MenuListEntry<Value = string> = MenuListEntryData<Value> & { ref?: typeof FloatingMenu | typeof MenuList };
export type MenuListEntries<Value = string> = MenuListEntry<Value>[];
export type SectionsOfMenuListEntries<Value = string> = MenuListEntries<Value>[];
export class DropdownInput extends WidgetProps {
entries!: SectionsOfMenuListEntries;
selectedIndex!: number | undefined;
drawIcon!: boolean;
interactive!: boolean;
disabled!: boolean;
}
export class FontInput extends WidgetProps {
fontFamily!: string;
fontStyle!: string;
isStyle!: boolean;
disabled!: boolean;
}
export class IconButton extends WidgetProps {
icon!: IconName;
size!: IconSize;
active!: boolean;
tooltip!: string;
}
export class IconLabel extends WidgetProps {
icon!: IconName;
iconStyle!: IconStyle | undefined;
}
export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
export class NumberInput extends WidgetProps {
label!: string | undefined;
value!: number | undefined;
min!: number | undefined;
max!: number | undefined;
isInteger!: boolean;
displayDecimalPlaces!: number;
unit!: string;
unitIsHiddenWhenEditing!: boolean;
incrementBehavior!: IncrementBehavior;
incrementFactor!: number;
disabled!: boolean;
}
export class OptionalInput extends WidgetProps {
checked!: boolean;
icon!: IconName;
tooltip!: string;
}
export class PopoverButton extends WidgetProps {
icon!: string | undefined;
// Body
header!: string;
text!: string;
}
export interface RadioEntryData {
value?: string;
label?: string;
icon?: IconName;
tooltip?: string;
// Callbacks
action?: () => void;
}
export type RadioEntries = RadioEntryData[];
export class RadioInput extends WidgetProps {
entries!: RadioEntries;
selectedIndex!: number;
}
export type SeparatorDirection = "Horizontal" | "Vertical";
export type SeparatorType = "Related" | "Unrelated" | "Section" | "List";
export class Separator extends WidgetProps {
direction!: SeparatorDirection;
type!: SeparatorType;
}
export class SwatchPairInput extends WidgetProps {
@Type(() => Color)
primary!: Color;
@Type(() => Color)
secondary!: Color;
}
export class TextAreaInput extends WidgetProps {
value!: string;
label!: string | undefined;
disabled!: boolean;
}
export class TextButton extends WidgetProps {
label!: string;
emphasized!: boolean;
minWidth!: number;
disabled!: boolean;
}
export class TextInput extends WidgetProps {
value!: string;
label!: string | undefined;
disabled!: boolean;
}
export class TextLabel extends WidgetProps {
// Body
value!: string;
// Props
bold!: boolean;
italic!: boolean;
tableAlign!: boolean;
multiline!: boolean;
}
// WIDGET
const widgetSubTypes = [
{ value: CheckboxInput, name: "CheckboxInput" },
{ value: ColorInput, name: "ColorInput" },
{ value: DropdownInput, name: "DropdownInput" },
{ value: FontInput, name: "FontInput" },
{ value: IconButton, name: "IconButton" },
{ value: IconLabel, name: "IconLabel" },
{ value: NumberInput, name: "NumberInput" },
{ value: OptionalInput, name: "OptionalInput" },
{ value: PopoverButton, name: "PopoverButton" },
{ value: RadioInput, name: "RadioInput" },
{ value: Separator, name: "Separator" },
{ value: SwatchPairInput, name: "SwatchPairInput" },
{ value: TextAreaInput, name: "TextAreaInput" },
{ value: TextButton, name: "TextButton" },
{ value: TextInput, name: "TextInput" },
{ value: TextLabel, name: "TextLabel" },
];
export type WidgetPropsSet = InstanceType<typeof widgetSubTypes[number]["value"]>;
export class Widget {
constructor(props: WidgetPropsSet, widgetId: bigint) {
this.props = props;
this.widgetId = widgetId;
}
@Type(() => WidgetProps, { discriminator: { property: "kind", subTypes: widgetSubTypes }, keepDiscriminatorProperty: true })
props!: WidgetPropsSet;
widgetId!: bigint;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hoistWidgetHolders(widgetHolders: any[]): Widget[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return widgetHolders.map((widgetHolder: any) => {
const kind = Object.keys(widgetHolder.widget)[0];
const props = widgetHolder.widget[kind];
props.kind = kind;
const { widgetId } = widgetHolder;
return plainToClass(Widget, { props, widgetId });
});
}
// WIDGET LAYOUT
export interface WidgetLayout {
layout: LayoutGroup[];
layout_target: unknown;
layout: LayoutGroup[];
}
export function defaultWidgetLayout(): WidgetLayout {
return {
layout: [],
layout_target: null,
layout: [],
};
}
@ -400,107 +651,27 @@ export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSect
return Boolean((layoutRow as WidgetSection).layout);
}
export type WidgetKind =
| "CheckboxInput"
| "ColorInput"
| "DropdownInput"
| "FontInput"
| "IconButton"
| "IconLabel"
| "NumberInput"
| "OptionalInput"
| "PopoverButton"
| "RadioInput"
| "Separator"
| "TextAreaInput"
| "TextButton"
| "TextInput"
| "TextLabel";
export interface Widget {
kind: WidgetKind;
widget_id: bigint;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: any;
}
export class UpdateDialogDetails extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateDocumentModeLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateToolOptionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateDocumentBarLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateToolShelfLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdatePropertyPanelOptionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdatePropertyPanelSectionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateLayerTreeOptionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
@Transform(({ value }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
// Unpacking rust types to more usable type in the frontend
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createWidgetLayout(widgetLayout: any[]): LayoutGroup[] {
return widgetLayout.map((layoutType): LayoutGroup => {
if (layoutType.Column) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const columnWidgets = hoistWidgetHolders(layoutType.Column.columnWidgets);
if (layoutType.column) {
const columnWidgets = hoistWidgetHolders(layoutType.column.columnWidgets);
const result: WidgetColumn = { columnWidgets };
return result;
}
if (layoutType.Row) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rowWidgets = hoistWidgetHolders(layoutType.Row.rowWidgets);
if (layoutType.row) {
const rowWidgets = hoistWidgetHolders(layoutType.row.rowWidgets);
const result: WidgetRow = { rowWidgets };
return result;
}
if (layoutType.Section) {
const { name } = layoutType.Section;
const layout = createWidgetLayout(layoutType.Section.layout);
if (layoutType.section) {
const { name } = layoutType.section;
const layout = createWidgetLayout(layoutType.section.layout);
const result: WidgetSection = { name, layout };
return result;
@ -509,25 +680,104 @@ function createWidgetLayout(widgetLayout: any[]): LayoutGroup[] {
throw new Error("Layout row type does not exist");
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hoistWidgetHolders(widgetHolders: any[]): Widget[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return widgetHolders.map((widgetHolder: any) => {
const { widget_id } = widgetHolder;
const kind = Object.keys(widgetHolder.widget)[0];
const props = widgetHolder.widget[kind];
return { widget_id, kind, props } as Widget;
});
// WIDGET LAYOUTS
export class UpdateDialogDetails extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateDocumentModeLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateToolOptionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateDocumentBarLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateToolShelfLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateWorkingColorsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdatePropertyPanelOptionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdatePropertyPanelSectionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateLayerTreeOptionsLayout extends JsMessage implements WidgetLayout {
layout_target!: unknown;
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createWidgetLayout(value))
layout!: LayoutGroup[];
}
export class UpdateMenuBarLayout extends JsMessage {
layout_target!: unknown;
@Transform(({ value }) => createMenuLayout(value))
// TODO: Replace `any` with correct typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Transform(({ value }: { value: any }) => createMenuLayout(value))
layout!: MenuColumn[];
}
export type MenuColumn = {
label: string;
children: MenuEntry[][];
};
export type MenuEntry = {
shortcut: string[] | undefined;
action: Widget;
@ -536,11 +786,6 @@ export type MenuEntry = {
children: undefined | MenuEntry[][];
};
export type MenuColumn = {
label: string;
children: MenuEntry[][];
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createMenuLayout(menuLayout: any[]): MenuColumn[] {
return menuLayout.map((column) => ({ ...column, children: createMenuLayoutRecursive(column.children) }));
@ -556,18 +801,6 @@ function createMenuLayoutRecursive(subLayout: any[][]): MenuEntry[][] {
);
}
export class TriggerTextCommit extends JsMessage {}
export class TriggerTextCopy extends JsMessage {
readonly copy_text!: string;
}
export class TriggerAboutGraphiteLocalizedCommitDate extends JsMessage {
readonly commit_date!: string;
}
export class TriggerViewportResize extends JsMessage {}
// `any` is used since the type of the object should be known from the Rust side
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JSMessageFactory = (data: any, wasm: WasmRawInstance, instance: WasmEditorInstance) => JsMessage;
@ -588,6 +821,7 @@ export const messageMakers: Record<string, MessageMaker> = {
TriggerIndexedDbWriteDocument,
TriggerPaste,
TriggerRasterDownload,
TriggerRefreshBoundsOfViewports,
TriggerTextCommit,
TriggerTextCopy,
TriggerAboutGraphiteLocalizedCommitDate,
@ -612,7 +846,7 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdateLayerTreeOptionsLayout,
UpdateDocumentModeLayout,
UpdateToolOptionsLayout,
UpdateWorkingColors,
UpdateWorkingColorsLayout,
UpdateMenuBarLayout,
} as const;
export type JsMessageType = keyof typeof messageMakers;