mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-03 21:08:18 +00:00
Integrate Stable Diffusion with the Imaginate layer (#784)
* Add AI Artist layer * WIP add a button to download the rendered folder under an AI Artist layer * Successfully download the correct image * Break out image downloading JS into helper function * Change file download from using data URLs to blob URLs * WIP rasterize to blob * Remove dimensions from AI Artist layer * Successfully draw rasterized image on layer after calculation * Working txt2img generation based on user prompt * Add img2img and the main parameters * Fix ability to rasterize multi-depth documents with blob URL images by switching them to base64 * Fix test * Rasterize with artboard background color * Allow aspect ratio stretch of AI Artist images * Add automatic resolution choosing * Add a terminate button, and make the lifecycle more robust * Add negative prompt * Add range bounds for parameter inputs * Add seed * Add tiling and restore faces * Add server status check, server hostname customization, and resizing layer to fit AI Artist resolution * Fix background color of infinite canvas rasterization * Escape prompt text sent in the JSON * Revoke blob URLs when cleared/replaced to reduce memory leak * Fix welcome screen logo color * Add PreferencesMessageHandler * Add persistent storage of preferences * Fix crash introduced in previous commit when moving mouse on page load * Add tooltips to the AI Artist layer properties * Integrate AI Artist tool into the raster section of the tool shelf * Add a refresh button to the connection status * Fix crash when generating and switching to a different document tab * Add persistent image storage to AI Artist layers and fix duplication bugs * Add a generate with random seed button * Simplify and standardize message names * Majorly improve robustness of networking code * Fix race condition causing default server hostname to show disconnected when app loads with AI Artist layer selected (probably, not confirmed fixed) * Clean up messages and function calls by changing arguments into structs * Update API to more recent server commit * Add support for picking the sampling method * Add machinery for filtering selected layers with type * Replace placeholder button icons * Improve the random icon by tilting the dice * Use selected_layers() instead of repeating that code * Fix borrow error * Change message flow in progress towards fixing #797 * Allow loading image on non-active document (fixes #797) * Reduce code duplication with rasterization * Add AI Artist tool and layer icons, and remove ugly node layer icon style * Rename "AI Artist" codename to "Imaginate" feature name Co-authored-by: otdavies <oliver@psyfer.io> Co-authored-by: 0hypercube <0hypercube@gmail.com>
This commit is contained in:
parent
562217015d
commit
fe1a03fac7
118 changed files with 3767 additions and 678 deletions
|
@ -217,7 +217,6 @@ 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";
|
||||
|
@ -236,7 +235,6 @@ import { createEditor, type Editor } from "@/wasm-communication/editor";
|
|||
import MainWindow from "@/components/window/MainWindow.vue";
|
||||
|
||||
const managerDestructors: {
|
||||
createBlobManager?: () => void;
|
||||
createClipboardManager?: () => void;
|
||||
createHyperlinkManager?: () => void;
|
||||
createInputManager?: () => void;
|
||||
|
@ -285,7 +283,6 @@ 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),
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
class="row"
|
||||
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: entry.disabled }"
|
||||
:style="{ height: virtualScrollingEntryHeight || '20px' }"
|
||||
:title="tooltip"
|
||||
@click="() => !entry.disabled && onEntryClick(entry)"
|
||||
@pointerenter="() => !entry.disabled && onEntryPointerEnter(entry)"
|
||||
@pointerleave="() => !entry.disabled && onEntryPointerLeave(entry)"
|
||||
|
@ -184,6 +185,7 @@ const MenuList = defineComponent({
|
|||
interactive: { type: Boolean as PropType<boolean>, default: false },
|
||||
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
||||
virtualScrollingEntryHeight: { type: Number as PropType<number>, default: 0 },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -45,17 +45,14 @@
|
|||
@click.alt="(e: MouseEvent) => e.stopPropagation()"
|
||||
>
|
||||
<LayoutRow class="layer-type-icon">
|
||||
<IconLabel v-if="listing.entry.layerType === 'Folder'" :icon="'NodeFolder'" :iconStyle="'Node'" title="Folder" />
|
||||
<IconLabel v-else-if="listing.entry.layerType === 'Image'" :icon="'NodeImage'" :iconStyle="'Node'" title="Image" />
|
||||
<IconLabel v-else-if="listing.entry.layerType === 'Shape'" :icon="'NodeShape'" :iconStyle="'Node'" title="Shape" />
|
||||
<IconLabel v-else-if="listing.entry.layerType === 'Text'" :icon="'NodeText'" :iconStyle="'Node'" title="Path" />
|
||||
<IconLabel :icon="layerTypeData(listing.entry.layerType).icon" :title="layerTypeData(listing.entry.layerType).name" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="layer-name" @dblclick="() => onEditLayerName(listing)">
|
||||
<input
|
||||
data-text-input
|
||||
type="text"
|
||||
:value="listing.entry.name"
|
||||
:placeholder="listing.entry.layerType"
|
||||
:placeholder="layerTypeData(listing.entry.layerType).name"
|
||||
:disabled="!listing.editingName"
|
||||
@blur="() => onEditLayerNameDeselect(listing)"
|
||||
@keydown.esc="onEditLayerNameDeselect(listing)"
|
||||
|
@ -268,7 +265,16 @@
|
|||
import { defineComponent, nextTick } from "vue";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import { type LayerPanelEntry, defaultWidgetLayout, UpdateDocumentLayerDetails, UpdateDocumentLayerTreeStructure, UpdateLayerTreeOptionsLayout } from "@/wasm-communication/messages";
|
||||
import {
|
||||
type LayerType,
|
||||
type LayerTypeData,
|
||||
type LayerPanelEntry,
|
||||
defaultWidgetLayout,
|
||||
UpdateDocumentLayerDetails,
|
||||
UpdateDocumentLayerTreeStructure,
|
||||
UpdateLayerTreeOptionsLayout,
|
||||
layerTypeData,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
@ -484,6 +490,9 @@ export default defineComponent({
|
|||
|
||||
recurse(updateDocumentLayerTreeStructure, this.layers, this.layerCache);
|
||||
},
|
||||
layerTypeData(layerType: LayerType): LayerTypeData {
|
||||
return layerTypeData(layerType) || { name: "Error", icon: "NodeText" };
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => {
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeImage'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeImage'" />
|
||||
<TextLabel>Image</TextLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -42,7 +42,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeMask'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeMask'" />
|
||||
<TextLabel>Mask</TextLabel>
|
||||
</div>
|
||||
<div class="arguments">
|
||||
|
@ -69,7 +69,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeTransform'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeTransform'" />
|
||||
<TextLabel>Transform</TextLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -83,7 +83,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeMotionBlur'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeMotionBlur'" />
|
||||
<TextLabel>Motion Blur</TextLabel>
|
||||
</div>
|
||||
<div class="arguments">
|
||||
|
@ -110,7 +110,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeShape'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeShape'" />
|
||||
<TextLabel>Shape</TextLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -124,7 +124,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeBrushwork'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeBrushwork'" />
|
||||
<TextLabel>Brushwork</TextLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -138,7 +138,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeBlur'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeBlur'" />
|
||||
<TextLabel>Blur</TextLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -152,7 +152,7 @@
|
|||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconLabel :icon="'NodeGradient'" :iconStyle="'Node'" />
|
||||
<IconLabel :icon="'NodeGradient'" />
|
||||
<TextLabel>Gradient</TextLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -21,12 +21,6 @@
|
|||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.widget-row > .icon-label:first-of-type {
|
||||
border-radius: 2px;
|
||||
background: var(--color-node-background);
|
||||
fill: var(--color-node-icon);
|
||||
}
|
||||
}
|
||||
|
||||
.sections {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<LayoutRow class="popover-button">
|
||||
<IconButton :action="() => onClick()" :icon="icon" :size="16" data-hover-menu-spawner />
|
||||
<IconButton :action="() => onClick()" :icon="icon" :size="16" data-hover-menu-spawner :tooltip="tooltip" />
|
||||
<FloatingMenu v-model:open="open" :type="'Popover'" :direction="'Bottom'">
|
||||
<slot></slot>
|
||||
</FloatingMenu>
|
||||
|
@ -58,6 +58,7 @@ import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
|||
export default defineComponent({
|
||||
props: {
|
||||
icon: { type: String as PropType<IconName>, default: "DropdownArrow" },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
|
||||
// Callbacks
|
||||
action: { type: Function as PropType<() => void>, required: false },
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
:data-emphasized="emphasized || undefined"
|
||||
:data-disabled="disabled || undefined"
|
||||
data-text-button
|
||||
:title="tooltip"
|
||||
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
|
||||
@click="(e: MouseEvent) => action(e)"
|
||||
>
|
||||
|
@ -71,23 +72,6 @@ import { type IconName } from "@/utility-functions/icons";
|
|||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||
|
||||
export type TextButtonWidget = {
|
||||
tooltip?: string;
|
||||
message?: string | object;
|
||||
callback?: () => void;
|
||||
props: {
|
||||
kind: "TextButton";
|
||||
label: string;
|
||||
icon?: string;
|
||||
emphasized?: boolean;
|
||||
minWidth?: number;
|
||||
disabled?: boolean;
|
||||
|
||||
// Callbacks
|
||||
// `action` is used via `IconButtonWidget.callback`
|
||||
};
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
label: { type: String as PropType<string>, required: true },
|
||||
|
@ -95,6 +79,7 @@ export default defineComponent({
|
|||
emphasized: { type: Boolean as PropType<boolean>, default: false },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
disabled: { 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 },
|
||||
|
|
|
@ -61,10 +61,14 @@
|
|||
.body {
|
||||
margin: 0 4px;
|
||||
|
||||
.text-label {
|
||||
.text-label:first-of-type {
|
||||
flex: 0 0 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
class="dropdown-box"
|
||||
:class="{ disabled, open }"
|
||||
:style="{ minWidth: `${minWidth}px` }"
|
||||
:title="tooltip"
|
||||
@click="() => !disabled && (open = true)"
|
||||
@blur="(e: FocusEvent) => blur(e)"
|
||||
@keydown="(e: KeyboardEvent) => keydown(e)"
|
||||
|
@ -115,6 +116,7 @@ export default defineComponent({
|
|||
drawIcon: { type: Boolean as PropType<boolean>, default: false },
|
||||
interactive: { type: Boolean as PropType<boolean>, default: true },
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
v-model="inputValue"
|
||||
:spellcheck="spellcheck"
|
||||
:disabled="disabled"
|
||||
:title="tooltip"
|
||||
@focus="() => $emit('textFocused')"
|
||||
@blur="() => $emit('textChanged')"
|
||||
@change="() => $emit('textChanged')"
|
||||
|
@ -26,6 +27,7 @@
|
|||
v-model="inputValue"
|
||||
:spellcheck="spellcheck"
|
||||
:disabled="disabled"
|
||||
:title="tooltip"
|
||||
@focus="() => $emit('textFocused')"
|
||||
@blur="() => $emit('textChanged')"
|
||||
@change="() => $emit('textChanged')"
|
||||
|
@ -55,6 +57,7 @@
|
|||
padding: 3px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:not(.disabled) label {
|
||||
|
@ -129,6 +132,7 @@ export default defineComponent({
|
|||
spellcheck: { type: Boolean as PropType<boolean>, default: false },
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
textarea: { type: Boolean as PropType<boolean>, default: false },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<LayoutRow class="font-input">
|
||||
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" tabindex="0" @click="toggleOpen" @keydown="keydown" data-hover-menu-spawner>
|
||||
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" :title="tooltip" tabindex="0" @click="toggleOpen" @keydown="keydown" data-hover-menu-spawner>
|
||||
<span>{{ activeEntry?.value || "" }}</span>
|
||||
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
|
||||
</LayoutRow>
|
||||
|
@ -87,6 +87,7 @@ export default defineComponent({
|
|||
fontStyle: { type: String as PropType<string>, required: true },
|
||||
isStyle: { type: Boolean as PropType<boolean>, default: false },
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
:label="label"
|
||||
:spellcheck="false"
|
||||
:disabled="disabled"
|
||||
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
|
||||
:tooltip="tooltip"
|
||||
@textFocused="() => onTextFocused()"
|
||||
@textChanged="() => onTextChanged()"
|
||||
@cancelTextChange="() => onCancelTextChange()"
|
||||
|
@ -107,6 +109,8 @@ export default defineComponent({
|
|||
incrementBehavior: { type: String as PropType<IncrementBehavior>, default: "Add" },
|
||||
incrementFactor: { type: Number as PropType<number>, default: 1 },
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
|
||||
// Callbacks
|
||||
incrementCallbackIncrease: { type: Function as PropType<() => void>, required: false },
|
||||
|
@ -122,7 +126,7 @@ export default defineComponent({
|
|||
onTextFocused() {
|
||||
if (this.value === undefined) this.text = "";
|
||||
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
|
||||
else this.text = `${this.value}${this.unit}`;
|
||||
else this.text = `${this.value}${unPluralize(this.unit, this.value)}`;
|
||||
|
||||
this.editing = true;
|
||||
|
||||
|
@ -201,7 +205,7 @@ export default defineComponent({
|
|||
|
||||
const displayValue = Math.round(value * roundingPower) / roundingPower;
|
||||
|
||||
return `${displayValue}${this.unit}`;
|
||||
return `${displayValue}${unPluralize(this.unit, value)}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
@ -222,4 +226,9 @@ export default defineComponent({
|
|||
},
|
||||
components: { FieldInput },
|
||||
});
|
||||
|
||||
function unPluralize(unit: string, value: number): string {
|
||||
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
|
||||
return unit;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -3,10 +3,11 @@
|
|||
:textarea="true"
|
||||
class="text-area-input"
|
||||
:class="{ 'has-label': label }"
|
||||
v-model:value="inputValue"
|
||||
:label="label"
|
||||
:spellcheck="true"
|
||||
:disabled="disabled"
|
||||
:tooltip="tooltip"
|
||||
v-model:value="inputValue"
|
||||
@textFocused="() => onTextFocused()"
|
||||
@textChanged="() => onTextChanged()"
|
||||
@cancelTextChange="() => onCancelTextChange()"
|
||||
|
@ -27,6 +28,7 @@ export default defineComponent({
|
|||
value: { type: String as PropType<string>, required: true },
|
||||
label: { type: String as PropType<string>, required: false },
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
:label="label"
|
||||
:spellcheck="true"
|
||||
:disabled="disabled"
|
||||
:tooltip="tooltip"
|
||||
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
|
||||
@textFocused="() => onTextFocused()"
|
||||
@textChanged="() => onTextChanged()"
|
||||
@cancelTextChange="() => onCancelTextChange()"
|
||||
|
@ -31,6 +33,8 @@ export default defineComponent({
|
|||
value: { type: String as PropType<string>, required: true },
|
||||
label: { type: String as PropType<string>, required: false },
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<LayoutRow :class="['icon-label', iconSizeClass, iconStyleClass]">
|
||||
<LayoutRow :class="['icon-label', iconSizeClass]" :title="tooltip">
|
||||
<component :is="icon" />
|
||||
</LayoutRow>
|
||||
</template>
|
||||
|
@ -23,35 +23,25 @@
|
|||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&.node-style {
|
||||
border-radius: 2px;
|
||||
background: var(--color-node-background);
|
||||
fill: var(--color-node-icon);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
|
||||
import { type IconName, type IconStyle, ICONS, ICON_COMPONENTS } from "@/utility-functions/icons";
|
||||
import { type IconName, ICONS, ICON_COMPONENTS } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
icon: { type: String as PropType<IconName>, required: true },
|
||||
iconStyle: { type: String as PropType<IconStyle | undefined>, required: false },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
computed: {
|
||||
iconSizeClass(): string {
|
||||
return `size-${ICONS[this.icon].size}`;
|
||||
},
|
||||
iconStyleClass(): string {
|
||||
if (!this.iconStyle || this.iconStyle === "Normal") return "";
|
||||
return `${this.iconStyle.toLowerCase()}-style`;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
LayoutRow,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<span class="text-label" :class="{ bold, italic, multiline, 'table-align': tableAlign }">
|
||||
<span class="text-label" :class="{ bold, italic, multiline, 'table-align': tableAlign }" :style="minWidth > 0 ? `min-width: ${minWidth}px` : ''" :title="tooltip">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
|
@ -37,7 +37,9 @@ export default defineComponent({
|
|||
bold: { type: Boolean as PropType<boolean>, default: false },
|
||||
italic: { type: Boolean as PropType<boolean>, default: false },
|
||||
tableAlign: { type: Boolean as PropType<boolean>, default: false },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
multiline: { type: Boolean as PropType<boolean>, default: false },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,15 +3,16 @@
|
|||
<LayoutRow class="tab-bar" data-tab-bar :class="{ 'min-widths': tabMinWidths }">
|
||||
<LayoutRow class="tab-group" :scrollableX="true">
|
||||
<LayoutRow
|
||||
class="tab"
|
||||
:class="{ active: tabIndex === tabActiveIndex }"
|
||||
data-tab
|
||||
v-for="(tabLabel, tabIndex) in tabLabels"
|
||||
:key="tabIndex"
|
||||
class="tab"
|
||||
:class="{ active: tabIndex === tabActiveIndex }"
|
||||
:title="tabLabel.tooltip || null"
|
||||
@click="(e: MouseEvent) => (e?.stopPropagation(), clickAction?.(tabIndex))"
|
||||
@click.middle="(e: MouseEvent) => (e?.stopPropagation(), closeAction?.(tabIndex))"
|
||||
data-tab
|
||||
>
|
||||
<span>{{ tabLabel }}</span>
|
||||
<span>{{ tabLabel.name }}</span>
|
||||
<IconButton :action="(e: MouseEvent) => (e?.stopPropagation(), closeAction?.(tabIndex))" :icon="'CloseX'" :size="16" v-if="tabCloseButtons" />
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
|
@ -31,7 +32,7 @@
|
|||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<TextButton :label="'New Document:'" :icon="'File'" :action="() => newDocument()" />
|
||||
<TextButton :label="'New Document'" :icon="'File'" :action="() => newDocument()" />
|
||||
</td>
|
||||
<td>
|
||||
<UserInputLabel :keysWithLabelsGroups="[[...platformModifiers(true), { key: 'KeyN', label: 'N' }]]" />
|
||||
|
@ -39,7 +40,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<TextButton :label="'Open Document:'" :icon="'Folder'" :action="() => openDocument()" />
|
||||
<TextButton :label="'Open Document'" :icon="'Folder'" :action="() => openDocument()" />
|
||||
</td>
|
||||
<td>
|
||||
<UserInputLabel :keysWithLabelsGroups="[[...platformModifiers(false), { key: 'KeyO', label: 'O' }]]" />
|
||||
|
@ -244,7 +245,7 @@ export default defineComponent({
|
|||
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 },
|
||||
tabLabels: { type: Array as PropType<{ name: string; tooltip?: string }[]>, required: true },
|
||||
tabActiveIndex: { type: Number as PropType<number>, required: true },
|
||||
panelType: { type: String as PropType<PanelTypes>, required: false },
|
||||
clickAction: { type: Function as PropType<(index: number) => void>, required: false },
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:panelType="portfolio.state.documents.length > 0 ? 'Document' : undefined"
|
||||
:tabCloseButtons="true"
|
||||
:tabMinWidths="true"
|
||||
:tabLabels="portfolio.state.documents.map((doc) => doc.displayName)"
|
||||
:tabLabels="portfolio.state.documents.map((doc) => ({ name: doc.displayName, tooltip: doc.id }))"
|
||||
:clickAction="(tabIndex: number) => editor.instance.selectDocument(portfolio.state.documents[tabIndex].id)"
|
||||
:closeAction="(tabIndex: number) => editor.instance.closeDocumentWithConfirmation(portfolio.state.documents[tabIndex].id)"
|
||||
:tabActiveIndex="portfolio.state.activeDocumentIndex"
|
||||
|
@ -16,17 +16,17 @@
|
|||
</LayoutRow>
|
||||
<LayoutRow class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)" v-if="nodeGraphVisible"></LayoutRow>
|
||||
<LayoutRow class="workspace-grid-subdivision" v-if="nodeGraphVisible">
|
||||
<Panel :panelType="'NodeGraph'" :tabLabels="['Node Graph']" :tabActiveIndex="0" />
|
||||
<Panel :panelType="'NodeGraph'" :tabLabels="[{ name: 'Node Graph' }]" :tabActiveIndex="0" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutCol>
|
||||
<LayoutCol class="workspace-grid-subdivision" style="flex-grow: 0.17">
|
||||
<LayoutCol class="workspace-grid-subdivision" style="flex-grow: 0.2">
|
||||
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 402">
|
||||
<Panel :panelType="'Properties'" :tabLabels="['Properties']" :tabActiveIndex="0" />
|
||||
<Panel :panelType="'Properties'" :tabLabels="[{ name: 'Properties' }]" :tabActiveIndex="0" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutRow>
|
||||
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 590">
|
||||
<Panel :panelType="'LayerTree'" :tabLabels="['Layer Tree']" :tabActiveIndex="0" />
|
||||
<Panel :panelType="'LayerTree'" :tabLabels="[{ name: 'Layer Tree' }]" :tabActiveIndex="0" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
import { type 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.imageData.forEach(async (element) => {
|
||||
// Using updateImageData.imageData.buffer returns undefined for some reason?
|
||||
const buffer = new Uint8Array(element.imageData.values()).buffer;
|
||||
const blob = new Blob([buffer], { type: element.mime });
|
||||
|
||||
// TODO: Call `URL.revokeObjectURL` at the appropriate time to avoid a memory leak
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
|
||||
const image = await createImageBitmap(blob);
|
||||
|
||||
editor.instance.setImageBlobUrl(element.path, blobURL, image.width, image.height);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -25,8 +25,8 @@ export function createPanicManager(editor: Editor, dialogState: DialogState): vo
|
|||
function preparePanicDialog(header: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
|
||||
const widgets: WidgetLayout = {
|
||||
layout: [
|
||||
{ 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)] },
|
||||
{ rowWidgets: [new Widget({ kind: "TextLabel", value: header, bold: true, italic: false, tableAlign: false, minWidth: 0, multiline: false, tooltip: "" }, 0n)] },
|
||||
{ rowWidgets: [new Widget({ kind: "TextLabel", value: details, bold: false, italic: false, tableAlign: false, minWidth: 0, multiline: true, tooltip: "" }, 1n)] },
|
||||
],
|
||||
layoutTarget: undefined,
|
||||
};
|
||||
|
|
|
@ -1,99 +1,155 @@
|
|||
import { type PortfolioState } from "@/state-providers/portfolio";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/wasm-communication/messages";
|
||||
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument, TriggerSavePreferences, TriggerLoadAutoSaveDocuments, TriggerLoadPreferences } from "@/wasm-communication/messages";
|
||||
|
||||
const GRAPHITE_INDEXED_DB_VERSION = 2;
|
||||
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
|
||||
const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents";
|
||||
|
||||
const GRAPHITE_AUTO_SAVE_STORE = { name: "auto-save-documents", keyPath: "details.id" };
|
||||
const GRAPHITE_EDITOR_PREFERENCES_STORE = { name: "editor-preferences", keyPath: "key" };
|
||||
|
||||
const GRAPHITE_INDEXEDDB_STORES = [GRAPHITE_AUTO_SAVE_STORE, GRAPHITE_EDITOR_PREFERENCES_STORE];
|
||||
|
||||
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export async function createPersistenceManager(editor: Editor, portfolio: PortfolioState): Promise<() => void> {
|
||||
function storeDocumentOrder(): void {
|
||||
// Make sure to store as string since JSON does not play nice with BigInt
|
||||
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
|
||||
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
|
||||
}
|
||||
export function createPersistenceManager(editor: Editor, portfolio: PortfolioState): () => void {
|
||||
async function initialize(): Promise<IDBDatabase> {
|
||||
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
|
||||
return new Promise<IDBDatabase>((resolve) => {
|
||||
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
|
||||
|
||||
async function removeDocument(id: string): Promise<void> {
|
||||
const db = await databaseConnection;
|
||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
|
||||
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id);
|
||||
storeDocumentOrder();
|
||||
}
|
||||
// Handle a version mismatch if `GRAPHITE_INDEXED_DB_VERSION` is now higher than what was saved in the database
|
||||
dbOpenRequest.onupgradeneeded = (): void => {
|
||||
const db = dbOpenRequest.result;
|
||||
|
||||
async function closeDatabaseConnection(): Promise<void> {
|
||||
const db = await databaseConnection;
|
||||
db.close();
|
||||
}
|
||||
// Wipe out all stores when a request is made to upgrade the database version to a newer one
|
||||
GRAPHITE_INDEXEDDB_STORES.forEach((store) => {
|
||||
if (db.objectStoreNames.contains(store.name)) db.deleteObjectStore(store.name);
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
|
||||
const db = await databaseConnection;
|
||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
|
||||
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument);
|
||||
storeDocumentOrder();
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
|
||||
removeDocument(removeAutoSaveDocument.documentId);
|
||||
});
|
||||
db.createObjectStore(store.name, { keyPath: store.keyPath });
|
||||
});
|
||||
};
|
||||
|
||||
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
|
||||
const databaseConnection = new Promise<IDBDatabase>((resolve) => {
|
||||
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
|
||||
|
||||
dbOpenRequest.onupgradeneeded = (): void => {
|
||||
const db = dbOpenRequest.result;
|
||||
// Wipes out all auto-save data on upgrade
|
||||
if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) {
|
||||
db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE);
|
||||
}
|
||||
|
||||
db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" });
|
||||
};
|
||||
|
||||
dbOpenRequest.onerror = (): void => {
|
||||
const errorText = stripIndents`
|
||||
// Handle some other error by presenting it to the user
|
||||
dbOpenRequest.onerror = (): void => {
|
||||
const errorText = stripIndents`
|
||||
Documents won't be saved across reloads and later visits.
|
||||
This may be caused by Firefox's private browsing mode.
|
||||
|
||||
Error on opening IndexDB:
|
||||
${dbOpenRequest.error}
|
||||
`;
|
||||
editor.instance.errorDialog("Document auto-save doesn't work in this browser", errorText);
|
||||
};
|
||||
editor.instance.errorDialog("Document auto-save doesn't work in this browser", errorText);
|
||||
};
|
||||
|
||||
dbOpenRequest.onsuccess = (): void => {
|
||||
resolve(dbOpenRequest.result);
|
||||
};
|
||||
});
|
||||
|
||||
databaseConnection.then(async (db) => {
|
||||
// Open auto-save documents
|
||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly");
|
||||
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll();
|
||||
await new Promise((resolve): void => {
|
||||
request.onsuccess = (): void => {
|
||||
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
|
||||
|
||||
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
|
||||
const orderedSavedDocuments = documentOrder
|
||||
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
|
||||
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
|
||||
|
||||
const currentDocumentVersion = editor.instance.graphiteDocumentVersion();
|
||||
orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => {
|
||||
if (doc.version === currentDocumentVersion) {
|
||||
editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document);
|
||||
} else {
|
||||
removeDocument(doc.details.id);
|
||||
}
|
||||
});
|
||||
resolve(undefined);
|
||||
// Resolve the promise on a successful opening of the database connection
|
||||
dbOpenRequest.onsuccess = (): void => {
|
||||
resolve(dbOpenRequest.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function storeDocumentOrder(): void {
|
||||
// Make sure to store as string since JSON does not play nice with BigInt
|
||||
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
|
||||
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
|
||||
}
|
||||
|
||||
async function removeDocument(id: string, db: IDBDatabase): Promise<void> {
|
||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite");
|
||||
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).delete(id);
|
||||
storeDocumentOrder();
|
||||
}
|
||||
|
||||
async function loadAutoSaveDocuments(db: IDBDatabase): Promise<void> {
|
||||
let promiseResolve: (value: void | PromiseLike<void>) => void;
|
||||
const promise = new Promise<void>((resolve): void => {
|
||||
promiseResolve = resolve;
|
||||
});
|
||||
|
||||
// Open auto-save documents
|
||||
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readonly");
|
||||
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).getAll();
|
||||
|
||||
request.onsuccess = (): void => {
|
||||
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
|
||||
|
||||
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
|
||||
const orderedSavedDocuments = documentOrder
|
||||
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
|
||||
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
|
||||
|
||||
const currentDocumentVersion = editor.instance.graphiteDocumentVersion();
|
||||
orderedSavedDocuments.forEach(async (doc: TriggerIndexedDbWriteDocument) => {
|
||||
if (doc.version === currentDocumentVersion) {
|
||||
editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document);
|
||||
} else {
|
||||
await removeDocument(doc.details.id, db);
|
||||
}
|
||||
});
|
||||
|
||||
promiseResolve();
|
||||
};
|
||||
|
||||
await promise;
|
||||
}
|
||||
|
||||
async function loadPreferences(db: IDBDatabase): Promise<void> {
|
||||
let promiseResolve: (value: void | PromiseLike<void>) => void;
|
||||
const promise = new Promise<void>((resolve): void => {
|
||||
promiseResolve = resolve;
|
||||
});
|
||||
|
||||
// Open auto-save documents
|
||||
const transaction = db.transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readonly");
|
||||
const request = transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).getAll();
|
||||
|
||||
request.onsuccess = (): void => {
|
||||
const preferenceEntries: { key: string; value: unknown }[] = request.result;
|
||||
|
||||
const preferences: Record<string, unknown> = {};
|
||||
preferenceEntries.forEach(({ key, value }) => {
|
||||
preferences[key] = value;
|
||||
});
|
||||
|
||||
editor.instance.loadPreferences(JSON.stringify(preferences));
|
||||
|
||||
promiseResolve();
|
||||
};
|
||||
|
||||
await promise;
|
||||
}
|
||||
|
||||
// Subscribe to process backend events
|
||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
|
||||
const transaction = (await databaseConnection).transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite");
|
||||
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).put(autoSaveDocument);
|
||||
|
||||
storeDocumentOrder();
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
|
||||
await removeDocument(removeAutoSaveDocument.documentId, await databaseConnection);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerLoadAutoSaveDocuments, async () => {
|
||||
await loadAutoSaveDocuments(await databaseConnection);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (preferences) => {
|
||||
Object.entries(preferences.preferences).forEach(async ([key, value]) => {
|
||||
const storedObject = { key, value };
|
||||
|
||||
const transaction = (await databaseConnection).transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readwrite");
|
||||
transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).put(storedObject);
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => {
|
||||
await loadPreferences(await databaseConnection);
|
||||
});
|
||||
|
||||
return closeDatabaseConnection;
|
||||
const databaseConnection = initialize();
|
||||
|
||||
// Destructor
|
||||
return () => {
|
||||
databaseConnection.then((connection) => connection.close());
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,7 +9,19 @@ import { initWasm } from "@/wasm-communication/editor";
|
|||
|
||||
import App from "@/App.vue";
|
||||
|
||||
// Browser app entry point
|
||||
(async (): Promise<void> => {
|
||||
// Confirm the browser is compatible before initializing the application
|
||||
if (!checkBrowserCompatibility()) return;
|
||||
|
||||
// Initialize the WASM module for the editor backend
|
||||
await initWasm();
|
||||
|
||||
// Initialize the Vue application
|
||||
createApp(App).mount("#app");
|
||||
})();
|
||||
|
||||
function checkBrowserCompatibility(): boolean {
|
||||
if (!("BigUint64Array" in window)) {
|
||||
const body = document.body;
|
||||
const message = stripIndents`
|
||||
|
@ -23,12 +35,9 @@ import App from "@/App.vue";
|
|||
JavaScript API must be supported by the browser for Graphite to function.)</p>
|
||||
`;
|
||||
body.innerHTML = message + body.innerHTML;
|
||||
return;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize the WASM module for the editor backend
|
||||
await initWasm();
|
||||
|
||||
// Initialize the Vue application
|
||||
createApp(App).mount("#app");
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { downloadFileText, downloadFileBlob, upload } from "@/utility-functions/files";
|
||||
import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate } from "@/utility-functions/imaginate";
|
||||
import { rasterizeSVG } from "@/utility-functions/rasterization";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import {
|
||||
|
@ -10,8 +11,13 @@ import {
|
|||
TriggerImport,
|
||||
TriggerOpenDocument,
|
||||
TriggerRasterDownload,
|
||||
TriggerImaginateGenerate,
|
||||
TriggerImaginateTerminate,
|
||||
TriggerImaginateCheckServerStatus,
|
||||
UpdateActiveDocument,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdateImageData,
|
||||
TriggerRevokeBlobUrl,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
|
@ -55,6 +61,47 @@ export function createPortfolioState(editor: Editor) {
|
|||
// Have the browser download the file to the user's disk
|
||||
downloadFileBlob(name, blob);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImaginateCheckServerStatus, async (triggerImaginateCheckServerStatus) => {
|
||||
const { hostname } = triggerImaginateCheckServerStatus;
|
||||
|
||||
imaginateCheckConnection(hostname, editor);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImaginateGenerate, async (triggerImaginateGenerate) => {
|
||||
const { documentId, layerPath, hostname, refreshFrequency, baseImage, parameters } = triggerImaginateGenerate;
|
||||
|
||||
// Handle img2img mode
|
||||
let image: Blob | undefined;
|
||||
if (parameters.denoisingStrength !== undefined && baseImage !== undefined) {
|
||||
// Rasterize the SVG to an image file
|
||||
image = await rasterizeSVG(baseImage.svg, baseImage.size[0], baseImage.size[1], "image/png");
|
||||
|
||||
const blobURL = URL.createObjectURL(image);
|
||||
|
||||
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, baseImage.size[0], baseImage.size[1]);
|
||||
}
|
||||
|
||||
imaginateGenerate(parameters, image, hostname, refreshFrequency, documentId, layerPath, editor);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerImaginateTerminate, async (triggerImaginateTerminate) => {
|
||||
const { documentId, layerPath, hostname } = triggerImaginateTerminate;
|
||||
|
||||
imaginateTerminate(hostname, documentId, layerPath, editor);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
|
||||
updateImageData.imageData.forEach(async (element) => {
|
||||
const buffer = new Uint8Array(element.imageData.values()).buffer;
|
||||
const blob = new Blob([buffer], { type: element.mime });
|
||||
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
|
||||
const image = await createImageBitmap(blob);
|
||||
|
||||
editor.instance.setImageBlobURL(updateImageData.documentId, element.path, blobURL, image.width, image.height);
|
||||
});
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerRevokeBlobUrl, async (triggerRevokeBlobUrl) => {
|
||||
URL.revokeObjectURL(triggerRevokeBlobUrl.url);
|
||||
});
|
||||
|
||||
return {
|
||||
state: readonly(state) as typeof state,
|
||||
|
|
14
frontend/src/utility-functions/escape.ts
Normal file
14
frontend/src/utility-functions/escape.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/* eslint-disable no-useless-escape */
|
||||
/* eslint-disable quotes */
|
||||
|
||||
export function escapeJSON(str: string): string {
|
||||
return str
|
||||
.replace(/[\\]/g, "\\\\")
|
||||
.replace(/[\"]/g, '\\"')
|
||||
.replace(/[\/]/g, "\\/")
|
||||
.replace(/[\b]/g, "\\b")
|
||||
.replace(/[\f]/g, "\\f")
|
||||
.replace(/[\n]/g, "\\n")
|
||||
.replace(/[\r]/g, "\\r")
|
||||
.replace(/[\t]/g, "\\t");
|
||||
}
|
|
@ -52,3 +52,31 @@ export async function upload<T extends "text" | "data">(acceptedExtensions: stri
|
|||
}
|
||||
export type UploadResult<T> = { filename: string; type: string; content: UploadResultType<T> };
|
||||
type UploadResultType<T> = T extends "text" ? string : T extends "data" ? Uint8Array : never;
|
||||
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = (): void => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceBlobURLsWithBase64(svg: string): Promise<string> {
|
||||
const splitByBlobs = svg.split(/(?<=")(blob:.*?)(?=")/);
|
||||
const onlyBlobs = splitByBlobs.filter((_, i) => i % 2 === 1);
|
||||
|
||||
const onlyBlobsConverted = onlyBlobs.map(async (blobURL) => {
|
||||
const data = await fetch(blobURL);
|
||||
const dataBlob = await data.blob();
|
||||
return blobToBase64(dataBlob);
|
||||
});
|
||||
const base64Images = await Promise.all(onlyBlobsConverted);
|
||||
|
||||
const substituted = splitByBlobs.map((segment, i) => {
|
||||
if (i % 2 === 0) return segment;
|
||||
|
||||
const blobsIndex = Math.floor(i / 2);
|
||||
return base64Images[blobsIndex];
|
||||
});
|
||||
return substituted.join("");
|
||||
}
|
||||
|
|
|
@ -101,6 +101,7 @@ import NodeColorCorrection from "@/../assets/icon-16px-solid/node-color-correcti
|
|||
import NodeFolder from "@/../assets/icon-16px-solid/node-folder.svg";
|
||||
import NodeGradient from "@/../assets/icon-16px-solid/node-gradient.svg";
|
||||
import NodeImage from "@/../assets/icon-16px-solid/node-image.svg";
|
||||
import NodeImaginate from "@/../assets/icon-16px-solid/node-imaginate.svg";
|
||||
import NodeMagicWand from "@/../assets/icon-16px-solid/node-magic-wand.svg";
|
||||
import NodeMask from "@/../assets/icon-16px-solid/node-mask.svg";
|
||||
import NodeMotionBlur from "@/../assets/icon-16px-solid/node-motion-blur.svg";
|
||||
|
@ -109,6 +110,12 @@ import NodeShape from "@/../assets/icon-16px-solid/node-shape.svg";
|
|||
import NodeText from "@/../assets/icon-16px-solid/node-text.svg";
|
||||
import NodeTransform from "@/../assets/icon-16px-solid/node-transform.svg";
|
||||
import Paste from "@/../assets/icon-16px-solid/paste.svg";
|
||||
import Random from "@/../assets/icon-16px-solid/random.svg";
|
||||
import Regenerate from "@/../assets/icon-16px-solid/regenerate.svg";
|
||||
import Reload from "@/../assets/icon-16px-solid/reload.svg";
|
||||
import Rescale from "@/../assets/icon-16px-solid/rescale.svg";
|
||||
import Reset from "@/../assets/icon-16px-solid/reset.svg";
|
||||
import Settings from "@/../assets/icon-16px-solid/settings.svg";
|
||||
import Trash from "@/../assets/icon-16px-solid/trash.svg";
|
||||
import ViewModeNormal from "@/../assets/icon-16px-solid/view-mode-normal.svg";
|
||||
import ViewModeOutline from "@/../assets/icon-16px-solid/view-mode-outline.svg";
|
||||
|
@ -142,6 +149,7 @@ const SOLID_16PX = {
|
|||
FlipVertical: { component: FlipVertical, size: 16 },
|
||||
Folder: { component: Folder, size: 16 },
|
||||
GraphiteLogo: { component: GraphiteLogo, size: 16 },
|
||||
NodeImaginate: { component: NodeImaginate, size: 16 },
|
||||
NodeArtboard: { component: NodeArtboard, size: 16 },
|
||||
NodeBlur: { component: NodeBlur, size: 16 },
|
||||
NodeBrushwork: { component: NodeBrushwork, size: 16 },
|
||||
|
@ -157,6 +165,12 @@ const SOLID_16PX = {
|
|||
NodeText: { component: NodeText, size: 16 },
|
||||
NodeTransform: { component: NodeTransform, size: 16 },
|
||||
Paste: { component: Paste, size: 16 },
|
||||
Random: { component: Random, size: 16 },
|
||||
Regenerate: { component: Regenerate, size: 16 },
|
||||
Reload: { component: Reload, size: 16 },
|
||||
Rescale: { component: Rescale, size: 16 },
|
||||
Reset: { component: Reset, size: 16 },
|
||||
Settings: { component: Settings, size: 16 },
|
||||
Trash: { component: Trash, size: 16 },
|
||||
ViewModeNormal: { component: ViewModeNormal, size: 16 },
|
||||
ViewModeOutline: { component: ViewModeOutline, size: 16 },
|
||||
|
@ -205,6 +219,7 @@ import RasterBrushTool from "@/../assets/icon-24px-two-tone/raster-brush-tool.sv
|
|||
import RasterCloneTool from "@/../assets/icon-24px-two-tone/raster-clone-tool.svg";
|
||||
import RasterDetailTool from "@/../assets/icon-24px-two-tone/raster-detail-tool.svg";
|
||||
import RasterHealTool from "@/../assets/icon-24px-two-tone/raster-heal-tool.svg";
|
||||
import RasterImaginateTool from "@/../assets/icon-24px-two-tone/raster-imaginate-tool.svg";
|
||||
import RasterPatchTool from "@/../assets/icon-24px-two-tone/raster-patch-tool.svg";
|
||||
import RasterRelightTool from "@/../assets/icon-24px-two-tone/raster-relight-tool.svg";
|
||||
import VectorEllipseTool from "@/../assets/icon-24px-two-tone/vector-ellipse-tool.svg";
|
||||
|
@ -220,10 +235,11 @@ import VectorTextTool from "@/../assets/icon-24px-two-tone/vector-text-tool.svg"
|
|||
const TWO_TONE_24PX = {
|
||||
GeneralArtboardTool: { component: GeneralArtboardTool, size: 24 },
|
||||
GeneralEyedropperTool: { component: GeneralEyedropperTool, size: 24 },
|
||||
GeneralNavigateTool: { component: GeneralNavigateTool, size: 24 },
|
||||
GeneralSelectTool: { component: GeneralSelectTool, size: 24 },
|
||||
GeneralFillTool: { component: GeneralFillTool, size: 24 },
|
||||
GeneralGradientTool: { component: GeneralGradientTool, size: 24 },
|
||||
GeneralNavigateTool: { component: GeneralNavigateTool, size: 24 },
|
||||
GeneralSelectTool: { component: GeneralSelectTool, size: 24 },
|
||||
RasterImaginateTool: { component: RasterImaginateTool, size: 24 },
|
||||
RasterBrushTool: { component: RasterBrushTool, size: 24 },
|
||||
RasterCloneTool: { component: RasterCloneTool, size: 24 },
|
||||
RasterDetailTool: { component: RasterDetailTool, size: 24 },
|
||||
|
@ -256,7 +272,6 @@ export const ICON_COMPONENTS = Object.fromEntries(Object.entries(ICONS).map(([na
|
|||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
export type IconSize = undefined | 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.
|
||||
|
|
439
frontend/src/utility-functions/imaginate.ts
Normal file
439
frontend/src/utility-functions/imaginate.ts
Normal file
|
@ -0,0 +1,439 @@
|
|||
import { escapeJSON } from "@/utility-functions/escape";
|
||||
import { blobToBase64 } from "@/utility-functions/files";
|
||||
import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network";
|
||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
|
||||
|
||||
const MAX_POLLING_RETRIES = 4;
|
||||
const SERVER_STATUS_CHECK_TIMEOUT = 5000;
|
||||
const SAMPLING_MODES_POLLING_UNSUPPORTED = ["DPM fast", "DPM adaptive"];
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let terminated = false;
|
||||
|
||||
let generatingAbortRequest: XMLHttpRequest | undefined;
|
||||
let pollingAbortController = new AbortController();
|
||||
let statusAbortController = new AbortController();
|
||||
|
||||
// PUBLICLY CALLABLE FUNCTIONS
|
||||
|
||||
export async function imaginateGenerate(
|
||||
parameters: ImaginateGenerationParameters,
|
||||
image: Blob | undefined,
|
||||
hostname: string,
|
||||
refreshFrequency: number,
|
||||
documentId: bigint,
|
||||
layerPath: BigUint64Array,
|
||||
editor: Editor
|
||||
): Promise<void> {
|
||||
// Ignore a request to generate a new image while another is already being generated
|
||||
if (generatingAbortRequest !== undefined) return;
|
||||
|
||||
terminated = false;
|
||||
|
||||
// Immediately set the progress to 0% so the backend knows to update its layout
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, 0, "Beginning");
|
||||
|
||||
// Initiate a request to the computation server
|
||||
const discloseUploadingProgress = (progress: number): void => {
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, progress * 100, "Uploading");
|
||||
};
|
||||
const { uploaded, result, xhr } = await generate(discloseUploadingProgress, hostname, image, parameters);
|
||||
generatingAbortRequest = xhr;
|
||||
|
||||
try {
|
||||
// Wait until the request is fully uploaded, which could be slow if the img2img source is large and the user is on a slow connection
|
||||
await uploaded;
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, 0, "Generating");
|
||||
|
||||
// Begin polling for updates to the in-progress image generation at the specified interval
|
||||
// Don't poll if the chosen interval is 0, or if the chosen sampling method does not support polling
|
||||
if (refreshFrequency > 0 && !SAMPLING_MODES_POLLING_UNSUPPORTED.includes(parameters.samplingMethod)) {
|
||||
const interval = Math.max(refreshFrequency * 1000, 500);
|
||||
scheduleNextPollingUpdate(interval, Date.now(), 0, editor, hostname, documentId, layerPath, parameters.resolution);
|
||||
}
|
||||
|
||||
// Wait for the final image to be returned by the initial request containing either the full image or the last frame if it was terminated by the user
|
||||
const { body, status } = await result;
|
||||
if (status < 200 || status > 299) {
|
||||
throw new Error(`Request to server failed to return a 200-level status code (${status})`);
|
||||
}
|
||||
|
||||
// Extract the final image from the response and convert it to a data blob
|
||||
// Highly unstable API
|
||||
const base64 = JSON.parse(body)?.data[0]?.[0] as string | undefined;
|
||||
if (typeof base64 !== "string" || !base64.startsWith("data:image/png;base64,")) throw new Error("Could not read final image result from server response");
|
||||
const blob = await (await fetch(base64)).blob();
|
||||
|
||||
// Send the backend an updated status
|
||||
const percent = terminated ? undefined : 100;
|
||||
const newStatus = terminated ? "Terminated" : "Idle";
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percent, newStatus);
|
||||
|
||||
// Send the backend a blob URL for the final image
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, parameters.resolution[0], parameters.resolution[1]);
|
||||
|
||||
// Send the backend the blob data to be stored persistently in the layer
|
||||
const u8Array = new Uint8Array(await blob.arrayBuffer());
|
||||
editor.instance.setImaginateImageData(documentId, layerPath, u8Array);
|
||||
} catch {
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminated");
|
||||
|
||||
await imaginateCheckConnection(hostname, editor);
|
||||
}
|
||||
|
||||
abortAndResetGenerating();
|
||||
abortAndResetPolling();
|
||||
}
|
||||
|
||||
export async function imaginateTerminate(hostname: string, documentId: bigint, layerPath: BigUint64Array, editor: Editor): Promise<void> {
|
||||
terminated = true;
|
||||
abortAndResetPolling();
|
||||
|
||||
try {
|
||||
await terminate(hostname);
|
||||
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminating");
|
||||
} catch {
|
||||
abortAndResetGenerating();
|
||||
abortAndResetPolling();
|
||||
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminated");
|
||||
|
||||
await imaginateCheckConnection(hostname, editor);
|
||||
}
|
||||
}
|
||||
|
||||
export async function imaginateCheckConnection(hostname: string, editor: Editor): Promise<void> {
|
||||
const serverReached = await checkConnection(hostname);
|
||||
editor.instance.setImaginateServerStatus(serverReached);
|
||||
}
|
||||
|
||||
// ABORTING AND RESETTING HELPERS
|
||||
|
||||
function abortAndResetGenerating(): void {
|
||||
generatingAbortRequest?.abort();
|
||||
generatingAbortRequest = undefined;
|
||||
}
|
||||
|
||||
function abortAndResetPolling(): void {
|
||||
pollingAbortController.abort();
|
||||
pollingAbortController = new AbortController();
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// POLLING IMPLEMENTATION DETAILS
|
||||
|
||||
function scheduleNextPollingUpdate(
|
||||
interval: number,
|
||||
timeoutBegan: number,
|
||||
pollingRetries: number,
|
||||
editor: Editor,
|
||||
hostname: string,
|
||||
documentId: bigint,
|
||||
layerPath: BigUint64Array,
|
||||
resolution: [number, number]
|
||||
): void {
|
||||
// Pick a future time that keeps to the user-requested interval if possible, but on slower connections will go as fast as possible without overlapping itself
|
||||
const nextPollTimeGoal = timeoutBegan + interval;
|
||||
const timeFromNow = Math.max(0, nextPollTimeGoal - Date.now());
|
||||
|
||||
timer = setTimeout(async () => {
|
||||
const nextTimeoutBegan = Date.now();
|
||||
|
||||
try {
|
||||
const [blob, percentComplete] = await pollImage(hostname);
|
||||
if (terminated) return;
|
||||
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, resolution[0], resolution[1]);
|
||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percentComplete, "Generating");
|
||||
|
||||
scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, resolution);
|
||||
} catch {
|
||||
if (generatingAbortRequest === undefined) return;
|
||||
|
||||
if (pollingRetries + 1 > MAX_POLLING_RETRIES) {
|
||||
abortAndResetGenerating();
|
||||
abortAndResetPolling();
|
||||
|
||||
await imaginateCheckConnection(hostname, editor);
|
||||
} else {
|
||||
scheduleNextPollingUpdate(interval, nextTimeoutBegan, pollingRetries + 1, editor, hostname, documentId, layerPath, resolution);
|
||||
}
|
||||
}
|
||||
}, timeFromNow);
|
||||
}
|
||||
|
||||
// API COMMUNICATION FUNCTIONS
|
||||
// These are highly unstable APIs that will need to be updated very frequently, so we currently assume usage of this exact commit from the server:
|
||||
// https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/7d6042b908c064774ee10961309d396eabdc6c4a
|
||||
|
||||
function endpoint(hostname: string): string {
|
||||
// Highly unstable API
|
||||
return `${hostname}api/predict/`;
|
||||
}
|
||||
|
||||
async function pollImage(hostname: string): Promise<[Blob, number]> {
|
||||
// Highly unstable API
|
||||
const result = await fetch(endpoint(hostname), {
|
||||
signal: pollingAbortController.signal,
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
referrer: hostname,
|
||||
referrerPolicy: "strict-origin-when-cross-origin",
|
||||
body: stripIndents`
|
||||
{
|
||||
"fn_index":3,
|
||||
"data":[],
|
||||
"session_hash":"0000000000"
|
||||
}`,
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
});
|
||||
const json = await result.json();
|
||||
// Highly unstable API
|
||||
const percentComplete = Math.abs(Number(json.data[0].match(/(?<="width:).*?(?=%")/)[0])); // The API sometimes returns negative values presumably due to a bug
|
||||
// Highly unstable API
|
||||
const base64 = json.data[2];
|
||||
|
||||
if (typeof base64 !== "string" || !base64.startsWith("data:image/png;base64,")) return Promise.reject();
|
||||
|
||||
const blob = await (await fetch(base64)).blob();
|
||||
|
||||
return [blob, percentComplete];
|
||||
}
|
||||
|
||||
async function generate(
|
||||
discloseUploadingProgress: (progress: number) => void,
|
||||
hostname: string,
|
||||
image: Blob | undefined,
|
||||
parameters: ImaginateGenerationParameters
|
||||
): Promise<{
|
||||
uploaded: Promise<void>;
|
||||
result: Promise<RequestResult>;
|
||||
xhr?: XMLHttpRequest;
|
||||
}> {
|
||||
let body;
|
||||
if (image === undefined || parameters.denoisingStrength === undefined) {
|
||||
// Highly unstable API
|
||||
body = stripIndents`
|
||||
{
|
||||
"fn_index":13,
|
||||
"data":[
|
||||
"${escapeJSON(parameters.prompt)}",
|
||||
"${escapeJSON(parameters.negativePrompt)}",
|
||||
"None",
|
||||
"None",
|
||||
${parameters.samples},
|
||||
"${parameters.samplingMethod}",
|
||||
${parameters.restoreFaces},
|
||||
${parameters.tiling},
|
||||
1,
|
||||
1,
|
||||
${parameters.cfgScale},
|
||||
${parameters.seed},
|
||||
-1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
${parameters.resolution[1]},
|
||||
${parameters.resolution[0]},
|
||||
false,
|
||||
0.7,
|
||||
0,
|
||||
0,
|
||||
"None",
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
"",
|
||||
"Seed",
|
||||
"",
|
||||
"Nothing",
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
""
|
||||
],
|
||||
"session_hash":"0000000000"
|
||||
}`;
|
||||
} else {
|
||||
const sourceImageBase64 = await blobToBase64(image);
|
||||
|
||||
// Highly unstable API
|
||||
body = stripIndents`
|
||||
{
|
||||
"fn_index":33,
|
||||
"data":[
|
||||
0,
|
||||
"${escapeJSON(parameters.prompt)}",
|
||||
"${escapeJSON(parameters.negativePrompt)}",
|
||||
"None",
|
||||
"None",
|
||||
"${sourceImageBase64}",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Draw mask",
|
||||
${parameters.samples},
|
||||
"${parameters.samplingMethod}",
|
||||
4,
|
||||
"fill",
|
||||
${parameters.restoreFaces},
|
||||
${parameters.tiling},
|
||||
1,
|
||||
1,
|
||||
${parameters.cfgScale},
|
||||
${parameters.denoisingStrength},
|
||||
${parameters.seed},
|
||||
-1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
${parameters.resolution[1]},
|
||||
${parameters.resolution[0]},
|
||||
"Just resize",
|
||||
false,
|
||||
32,
|
||||
"Inpaint masked",
|
||||
"",
|
||||
"",
|
||||
"None",
|
||||
"",
|
||||
true,
|
||||
true,
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
50,
|
||||
true,
|
||||
1,
|
||||
0,
|
||||
false,
|
||||
4,
|
||||
1,
|
||||
"",
|
||||
128,
|
||||
8,
|
||||
["left","right","up","down"],
|
||||
1,
|
||||
0.05,
|
||||
128,
|
||||
4,
|
||||
"fill",
|
||||
["left","right","up","down"],
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
64,
|
||||
"None",
|
||||
"Seed",
|
||||
"",
|
||||
"Nothing",
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
"",
|
||||
""
|
||||
],
|
||||
"session_hash":"0000000000"
|
||||
}`;
|
||||
}
|
||||
|
||||
// Prepare a promise that will resolve after the outbound request upload is complete
|
||||
let uploadedResolve: () => void;
|
||||
let uploadedReject: () => void;
|
||||
const uploaded = new Promise<void>((resolve, reject): void => {
|
||||
uploadedResolve = resolve;
|
||||
uploadedReject = reject;
|
||||
});
|
||||
|
||||
// Fire off the request and, once the outbound request upload is complete, resolve the promise we defined above
|
||||
const uploadProgress = (progress: number): void => {
|
||||
if (progress < 1) {
|
||||
discloseUploadingProgress(progress);
|
||||
} else {
|
||||
uploadedResolve();
|
||||
}
|
||||
};
|
||||
const [result, xhr] = requestWithUploadDownloadProgress(endpoint(hostname), "POST", body, uploadProgress, abortAndResetPolling);
|
||||
result.catch(() => uploadedReject());
|
||||
|
||||
// Return the promise that resolves when the request upload is complete, the promise that resolves when the response download is complete, and the XHR so it can be aborted
|
||||
return { uploaded, result, xhr };
|
||||
}
|
||||
|
||||
async function terminate(hostname: string): Promise<void> {
|
||||
const body = stripIndents`
|
||||
{
|
||||
"fn_index":2,
|
||||
"data":[],
|
||||
"session_hash":"0000000000"
|
||||
}`;
|
||||
|
||||
await fetch(endpoint(hostname), {
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
referrer: hostname,
|
||||
referrerPolicy: "strict-origin-when-cross-origin",
|
||||
body,
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
});
|
||||
}
|
||||
|
||||
async function checkConnection(hostname: string): Promise<boolean> {
|
||||
statusAbortController.abort();
|
||||
statusAbortController = new AbortController();
|
||||
|
||||
const timeout = setTimeout(() => statusAbortController.abort(), SERVER_STATUS_CHECK_TIMEOUT);
|
||||
|
||||
const body = stripIndents`
|
||||
{
|
||||
"fn_index":100,
|
||||
"data":[],
|
||||
"session_hash":"0000000000"
|
||||
}`;
|
||||
|
||||
try {
|
||||
await fetch(endpoint(hostname), {
|
||||
signal: statusAbortController.signal,
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
referrer: hostname,
|
||||
referrerPolicy: "strict-origin-when-cross-origin",
|
||||
body,
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
33
frontend/src/utility-functions/network.ts
Normal file
33
frontend/src/utility-functions/network.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
export type RequestResult = { body: string; status: number };
|
||||
|
||||
// Special implementation using the legacy XMLHttpRequest API that provides callbacks to get:
|
||||
// - Calls with the percent progress uploading the request to the server
|
||||
// - Calls when downloading the result from the server, after the server has begun streaming back the response data
|
||||
// It returns a tuple of the promise as well as the XHR which can be used to call the `.abort()` method on it.
|
||||
export function requestWithUploadDownloadProgress(
|
||||
url: string,
|
||||
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH",
|
||||
body: string,
|
||||
uploadProgress: (progress: number) => void,
|
||||
downloadOccurring: () => void
|
||||
): [Promise<RequestResult>, XMLHttpRequest | undefined] {
|
||||
let xhrValue: XMLHttpRequest | undefined;
|
||||
const promise = new Promise<RequestResult>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) => uploadProgress(e.loaded / e.total));
|
||||
xhr.addEventListener("progress", () => downloadOccurring());
|
||||
xhr.addEventListener("load", () => resolve({ status: xhr.status, body: xhr.responseText }));
|
||||
xhr.addEventListener("abort", () => resolve({ status: xhr.status, body: xhr.responseText }));
|
||||
xhr.addEventListener("error", () => reject(new Error("Request error")));
|
||||
xhr.open(method, url, true);
|
||||
xhr.setRequestHeader("accept", "*/*");
|
||||
xhr.setRequestHeader("accept-language", "en-US,en;q=0.9");
|
||||
xhr.setRequestHeader("content-type", "application/json");
|
||||
|
||||
xhrValue = xhr;
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
|
||||
return [promise, xhrValue];
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import { replaceBlobURLsWithBase64 } from "@/utility-functions/files";
|
||||
|
||||
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
|
||||
export function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
||||
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
||||
let promiseResolve: (value: Blob | PromiseLike<Blob>) => void | undefined;
|
||||
let promiseReject: () => void | undefined;
|
||||
const promise = new Promise<Blob>((resolve, reject) => {
|
||||
|
@ -21,9 +23,12 @@ export function rasterizeSVG(svg: string, width: number, height: number, mime: s
|
|||
context.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
// This SVG rasterization scheme has the limitation that it cannot access blob URLs, so they must be inlined to base64 URLs
|
||||
const svgWithBase64Images = await replaceBlobURLsWithBase64(svg);
|
||||
|
||||
// Create a blob URL for our SVG
|
||||
const image = new Image();
|
||||
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgBlob = new Blob([svgWithBase64Images], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
image.onload = (): void => {
|
||||
// Draw our SVG to the canvas
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { Transform, Type, plainToClass } from "class-transformer";
|
||||
|
||||
import { type IconName, type IconSize, type IconStyle } from "@/utility-functions/icons";
|
||||
import { type IconName, type IconSize } from "@/utility-functions/icons";
|
||||
import { type WasmEditorInstance, type WasmRawInstance } from "@/wasm-communication/editor";
|
||||
|
||||
import type MenuList from "@/components/floating-menus/MenuList.vue";
|
||||
|
@ -165,7 +165,8 @@ export class UpdateDocumentArtboards extends JsMessage {
|
|||
readonly svg!: string;
|
||||
}
|
||||
|
||||
const TupleToVec2 = Transform(({ value }: { value: [number, number] }) => ({ x: value[0], y: value[1] }));
|
||||
const TupleToVec2 = Transform(({ value }: { value: [number, number] | undefined }) => (value === undefined ? undefined : { x: value[0], y: value[1] }));
|
||||
const BigIntTupleToNumberTuple = Transform(({ value }: { value: [bigint, bigint] | undefined }) => (value === undefined ? undefined : [Number(value[0]), Number(value[1])]));
|
||||
|
||||
export type XY = { x: number; y: number };
|
||||
|
||||
|
@ -215,6 +216,10 @@ export class TriggerFileDownload extends JsMessage {
|
|||
readonly name!: string;
|
||||
}
|
||||
|
||||
export class TriggerLoadAutoSaveDocuments extends JsMessage {}
|
||||
|
||||
export class TriggerLoadPreferences extends JsMessage {}
|
||||
|
||||
export class TriggerOpenDocument extends JsMessage {}
|
||||
|
||||
export class TriggerImport extends JsMessage {}
|
||||
|
@ -232,8 +237,73 @@ export class TriggerRasterDownload extends JsMessage {
|
|||
readonly size!: XY;
|
||||
}
|
||||
|
||||
export class TriggerImaginateCheckServerStatus extends JsMessage {
|
||||
readonly hostname!: string;
|
||||
}
|
||||
|
||||
export class TriggerImaginateGenerate extends JsMessage {
|
||||
@Type(() => ImaginateGenerationParameters)
|
||||
readonly parameters!: ImaginateGenerationParameters;
|
||||
|
||||
@Type(() => ImaginateBaseImage)
|
||||
readonly baseImage!: ImaginateBaseImage | undefined;
|
||||
|
||||
readonly hostname!: string;
|
||||
|
||||
readonly refreshFrequency!: number;
|
||||
|
||||
readonly documentId!: bigint;
|
||||
|
||||
readonly layerPath!: BigUint64Array;
|
||||
}
|
||||
|
||||
export class ImaginateBaseImage {
|
||||
readonly svg!: string;
|
||||
|
||||
readonly size!: [number, number];
|
||||
}
|
||||
|
||||
export class ImaginateGenerationParameters {
|
||||
readonly seed!: number;
|
||||
|
||||
readonly samples!: number;
|
||||
|
||||
readonly samplingMethod!: string;
|
||||
|
||||
readonly denoisingStrength!: number | undefined;
|
||||
|
||||
readonly cfgScale!: number;
|
||||
|
||||
readonly prompt!: string;
|
||||
|
||||
readonly negativePrompt!: string;
|
||||
|
||||
@BigIntTupleToNumberTuple
|
||||
readonly resolution!: [number, number];
|
||||
|
||||
readonly restoreFaces!: boolean;
|
||||
|
||||
readonly tiling!: boolean;
|
||||
}
|
||||
|
||||
export class TriggerImaginateTerminate extends JsMessage {
|
||||
readonly documentId!: bigint;
|
||||
|
||||
readonly layerPath!: BigUint64Array;
|
||||
|
||||
readonly hostname!: string;
|
||||
}
|
||||
|
||||
export class TriggerRefreshBoundsOfViewports extends JsMessage {}
|
||||
|
||||
export class TriggerRevokeBlobUrl extends JsMessage {
|
||||
readonly url!: string;
|
||||
}
|
||||
|
||||
export class TriggerSavePreferences extends JsMessage {
|
||||
readonly preferences!: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class DocumentChanged extends JsMessage {}
|
||||
|
||||
export class UpdateDocumentLayerTreeStructure extends JsMessage {
|
||||
|
@ -313,8 +383,10 @@ export class DisplayEditableTextbox extends JsMessage {
|
|||
}
|
||||
|
||||
export class UpdateImageData extends JsMessage {
|
||||
@Type(() => ImageData)
|
||||
readonly imageData!: ImageData[];
|
||||
readonly documentId!: bigint;
|
||||
|
||||
@Type(() => ImaginateImageData)
|
||||
readonly imageData!: ImaginateImageData[];
|
||||
}
|
||||
|
||||
export class DisplayRemoveEditableTextbox extends JsMessage {}
|
||||
|
@ -327,7 +399,8 @@ export class UpdateDocumentLayerDetails extends JsMessage {
|
|||
export class LayerPanelEntry {
|
||||
name!: string;
|
||||
|
||||
tooltip!: string;
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
|
||||
visible!: boolean;
|
||||
|
||||
|
@ -348,9 +421,26 @@ export class LayerMetadata {
|
|||
selected!: boolean;
|
||||
}
|
||||
|
||||
export type LayerType = "Folder" | "Image" | "Shape" | "Text";
|
||||
export type LayerType = "Imaginate" | "Folder" | "Image" | "Shape" | "Text";
|
||||
|
||||
export class ImageData {
|
||||
export type LayerTypeData = {
|
||||
name: string;
|
||||
icon: IconName;
|
||||
};
|
||||
|
||||
export function layerTypeData(layerType: LayerType): LayerTypeData | undefined {
|
||||
const entries: Record<string, LayerTypeData> = {
|
||||
Imaginate: { name: "Imaginate", icon: "NodeImaginate" },
|
||||
Folder: { name: "Folder", icon: "NodeFolder" },
|
||||
Image: { name: "Image", icon: "NodeImage" },
|
||||
Shape: { name: "Shape", icon: "NodeShape" },
|
||||
Text: { name: "Text", icon: "NodeText" },
|
||||
};
|
||||
|
||||
return entries[layerType];
|
||||
}
|
||||
|
||||
export class ImaginateImageData {
|
||||
readonly path!: BigUint64Array;
|
||||
|
||||
readonly mime!: string;
|
||||
|
@ -400,7 +490,8 @@ export class CheckboxInput extends WidgetProps {
|
|||
|
||||
icon!: IconName;
|
||||
|
||||
tooltip!: string;
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class ColorInput extends WidgetProps {
|
||||
|
@ -412,7 +503,8 @@ export class ColorInput extends WidgetProps {
|
|||
|
||||
disabled!: boolean;
|
||||
|
||||
tooltip!: string;
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
type MenuEntryCommon = {
|
||||
|
@ -435,6 +527,7 @@ export type MenuListEntry = MenuEntryCommon & {
|
|||
shortcutRequiresLock?: boolean;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
font?: URL;
|
||||
ref?: InstanceType<typeof MenuList>;
|
||||
};
|
||||
|
@ -449,6 +542,9 @@ export class DropdownInput extends WidgetProps {
|
|||
interactive!: boolean;
|
||||
|
||||
disabled!: boolean;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class FontInput extends WidgetProps {
|
||||
|
@ -459,6 +555,9 @@ export class FontInput extends WidgetProps {
|
|||
isStyle!: boolean;
|
||||
|
||||
disabled!: boolean;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class IconButton extends WidgetProps {
|
||||
|
@ -468,13 +567,15 @@ export class IconButton extends WidgetProps {
|
|||
|
||||
active!: boolean;
|
||||
|
||||
tooltip!: string;
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class IconLabel extends WidgetProps {
|
||||
icon!: IconName;
|
||||
|
||||
iconStyle!: IconStyle | undefined;
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
|
||||
|
@ -501,6 +602,11 @@ export class NumberInput extends WidgetProps {
|
|||
incrementFactor!: number;
|
||||
|
||||
disabled!: boolean;
|
||||
|
||||
minWidth!: number;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class OptionalInput extends WidgetProps {
|
||||
|
@ -508,7 +614,8 @@ export class OptionalInput extends WidgetProps {
|
|||
|
||||
icon!: IconName;
|
||||
|
||||
tooltip!: string;
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class PopoverButton extends WidgetProps {
|
||||
|
@ -518,6 +625,9 @@ export class PopoverButton extends WidgetProps {
|
|||
header!: string;
|
||||
|
||||
text!: string;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export type RadioEntryData = {
|
||||
|
@ -560,6 +670,9 @@ export class TextAreaInput extends WidgetProps {
|
|||
label!: string | undefined;
|
||||
|
||||
disabled!: boolean;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class TextButton extends WidgetProps {
|
||||
|
@ -572,6 +685,9 @@ export class TextButton extends WidgetProps {
|
|||
minWidth!: number;
|
||||
|
||||
disabled!: boolean;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export type TextButtonWidget = {
|
||||
|
@ -585,6 +701,7 @@ export type TextButtonWidget = {
|
|||
emphasized?: boolean;
|
||||
minWidth?: number;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
|
||||
// Callbacks
|
||||
// `action` is used via `IconButtonWidget.callback`
|
||||
|
@ -597,6 +714,11 @@ export class TextInput extends WidgetProps {
|
|||
label!: string | undefined;
|
||||
|
||||
disabled!: boolean;
|
||||
|
||||
minWidth!: number;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export class TextLabel extends WidgetProps {
|
||||
|
@ -610,7 +732,12 @@ export class TextLabel extends WidgetProps {
|
|||
|
||||
tableAlign!: boolean;
|
||||
|
||||
minWidth!: number;
|
||||
|
||||
multiline!: boolean;
|
||||
|
||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||
tooltip!: string | undefined;
|
||||
}
|
||||
|
||||
export type PivotPosition = "None" | "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight";
|
||||
|
@ -851,15 +978,22 @@ export const messageMakers: Record<string, MessageMaker> = {
|
|||
DisplayEditableTextbox,
|
||||
DisplayRemoveEditableTextbox,
|
||||
TriggerAboutGraphiteLocalizedCommitDate,
|
||||
TriggerOpenDocument,
|
||||
TriggerImaginateCheckServerStatus,
|
||||
TriggerImaginateGenerate,
|
||||
TriggerImaginateTerminate,
|
||||
TriggerFileDownload,
|
||||
TriggerFontLoad,
|
||||
TriggerImport,
|
||||
TriggerIndexedDbRemoveDocument,
|
||||
TriggerIndexedDbWriteDocument,
|
||||
TriggerLoadAutoSaveDocuments,
|
||||
TriggerLoadPreferences,
|
||||
TriggerOpenDocument,
|
||||
TriggerPaste,
|
||||
TriggerRasterDownload,
|
||||
TriggerRefreshBoundsOfViewports,
|
||||
TriggerRevokeBlobUrl,
|
||||
TriggerSavePreferences,
|
||||
TriggerTextCommit,
|
||||
TriggerTextCopy,
|
||||
TriggerViewportResize,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue