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 15:09:13 -07:00
committed by GitHub
co-authored by Keavon Chambers 0hypercube
parent b6793517e2
commit 010ba5a568
77 changed files with 1859 additions and 1136 deletions
+121 -162
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,
},
});
+13 -13
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);
+8 -8
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>