mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-17 07:18:04 +08:00
Restructure project directories (#333)
`/client/web` -> `/frontend` `/client/cli` -> *delete for now* `/client/native` -> *delete for now* `/core/editor` -> `/editor` `/core/document` -> `/graphene` `/core/renderer` -> `/charcoal` `/core/proc-macro` -> `/proc-macros` *(now plural)*
This commit is contained in:
243
frontend/src/App.vue
Normal file
243
frontend/src/App.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<MainWindow />
|
||||
<div class="unsupported-modal-backdrop" v-if="showUnsupportedModal">
|
||||
<div class="unsupported-modal">
|
||||
<h2>Your browser currently doesn't support Graphite</h2>
|
||||
<p>
|
||||
Unfortunately, some features won't work properly in your browser. Please use a modern browser other than Safari, such as Firefox, Chrome, or Edge. Rest assured, Safari compatibility is
|
||||
planned.
|
||||
</p>
|
||||
<p>
|
||||
Your browser is missing support for the
|
||||
<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt64Array#browser_compatibility" target="_blank"><code>BigInt64Array</code></a> JavaScript
|
||||
API which is required for using the editor. You can still explore the user interface.
|
||||
</p>
|
||||
<LayoutRow> <button class="unsupported-modal-button" @click="closeModal()">I understand, let's just see the interface</button> </LayoutRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--color-0-black: #000;
|
||||
--color-0-black-rgb: 0, 0, 0;
|
||||
--color-1-nearblack: #111;
|
||||
--color-1-nearblack-rgb: 17, 17, 17;
|
||||
--color-2-mildblack: #222;
|
||||
--color-2-mildblack-rgb: 34, 34, 34;
|
||||
--color-3-darkgray: #333;
|
||||
--color-3-darkgray-rgb: 51, 51, 51;
|
||||
--color-4-dimgray: #444;
|
||||
--color-4-dimgray-rgb: 68, 68, 68;
|
||||
--color-5-dullgray: #555;
|
||||
--color-5-dullgray-rgb: 85, 85, 85;
|
||||
--color-6-lowergray: #666;
|
||||
--color-6-lowergray-rgb: 102, 102, 102;
|
||||
--color-7-middlegray: #777;
|
||||
--color-7-middlegray-rgb: 109, 109, 109;
|
||||
--color-8-uppergray: #888;
|
||||
--color-8-uppergray-rgb: 136, 136, 136;
|
||||
--color-9-palegray: #999;
|
||||
--color-9-palegray-rgb: 153, 153, 153;
|
||||
--color-a-softgray: #aaa;
|
||||
--color-a-softgray-rgb: 170, 170, 170;
|
||||
--color-b-lightgray: #bbb;
|
||||
--color-b-lightgray-rgb: 187, 187, 187;
|
||||
--color-c-brightgray: #ccc;
|
||||
--color-c-brightgray-rgb: 204, 204, 204;
|
||||
--color-d-mildwhite: #ddd;
|
||||
--color-d-mildwhite-rgb: 221, 221, 221;
|
||||
--color-e-nearwhite: #eee;
|
||||
--color-e-nearwhite-rgb: 238, 238, 238;
|
||||
--color-f-white: #fff;
|
||||
--color-f-white-rgb: 255, 255, 255;
|
||||
--color-accent: #3194d6;
|
||||
--color-accent-rgb: 49, 148, 214;
|
||||
--color-accent-hover: #49a5e2;
|
||||
--color-accent-hover-rgb: 73, 165, 226;
|
||||
--color-accent-disabled: #416277;
|
||||
--color-accent-disabled-rgb: 65, 98, 119;
|
||||
|
||||
// TODO: Replace with CSS color() function to calculate alpha when browsers support it
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color() and https://caniuse.com/css-color-function
|
||||
// F2 = 95% alpha
|
||||
--floating-menu-opacity-color-2-mildblack: #222222f2;
|
||||
--floating-menu-shadow: rgba(0, 0, 0, 50%);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--color-2-mildblack);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body,
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font-family: "Source Sans Pro", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
svg,
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scrollable,
|
||||
.scrollable-x,
|
||||
.scrollable-y {
|
||||
// Standard
|
||||
scrollbar-width: thin;
|
||||
scrollbar-width: 6px;
|
||||
scrollbar-gutter: 6px;
|
||||
scrollbar-color: var(--color-5-dullgray) transparent;
|
||||
|
||||
&:not(:hover) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
// WebKit
|
||||
&::-webkit-scrollbar {
|
||||
width: calc(2px + 6px + 2px);
|
||||
}
|
||||
|
||||
&:not(:hover)::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
box-shadow: inset 0 0 0 1px var(--color-5-dullgray);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-clip: padding-box;
|
||||
background-color: var(--color-5-dullgray);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
// Standard
|
||||
overflow: auto;
|
||||
// WebKit
|
||||
overflow: overlay;
|
||||
}
|
||||
|
||||
.scrollable-x {
|
||||
// Standard
|
||||
overflow-x: auto;
|
||||
// WebKit
|
||||
overflow-x: overlay;
|
||||
}
|
||||
|
||||
.scrollable-y {
|
||||
// Standard
|
||||
overflow-y: auto;
|
||||
// WebKit
|
||||
overflow-y: overlay;
|
||||
}
|
||||
|
||||
// For placeholder messages (remove eventually)
|
||||
.floating-menu {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.unsupported-modal-backdrop {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
.unsupported-modal {
|
||||
background: var(--color-3-darkgray);
|
||||
border-radius: 4px;
|
||||
box-shadow: 2px 2px 5px 0 var(--floating-menu-shadow);
|
||||
padding: 0 16px 16px 16px;
|
||||
border: 1px solid var(--color-4-dimgray);
|
||||
max-width: 500px;
|
||||
|
||||
& a {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
}
|
||||
.unsupported-modal-button {
|
||||
flex: 1;
|
||||
background: var(--color-1-nearblack);
|
||||
border: 0 none;
|
||||
padding: 12px;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--color-accent-hover);
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import dialog from "@/utilities/dialog";
|
||||
import documents from "@/utilities/documents";
|
||||
import fullscreen from "@/utilities/fullscreen";
|
||||
import MainWindow from "@/components/window/MainWindow.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
|
||||
export default defineComponent({
|
||||
provide: {
|
||||
dialog,
|
||||
documents,
|
||||
fullscreen,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showUnsupportedModal: !("BigInt64Array" in window),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.showUnsupportedModal = false;
|
||||
},
|
||||
},
|
||||
components: { MainWindow, LayoutRow },
|
||||
});
|
||||
</script>
|
||||
23
frontend/src/components/layout/LayoutCol.vue
Normal file
23
frontend/src/components/layout/LayoutCol.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div :class="['layout-col']">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({});
|
||||
</script>
|
||||
23
frontend/src/components/layout/LayoutRow.vue
Normal file
23
frontend/src/components/layout/LayoutRow.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div :class="['layout-row']">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({});
|
||||
</script>
|
||||
393
frontend/src/components/panels/Document.vue
Normal file
393
frontend/src/components/panels/Document.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<LayoutCol :class="'document'">
|
||||
<LayoutRow :class="'options-bar'">
|
||||
<div class="left side">
|
||||
<DropdownInput :menuEntries="documentModeEntries" v-model:selectedIndex="documentModeSelectionIndex" :drawIcon="true" />
|
||||
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
|
||||
<ToolOptions :activeTool="activeTool" />
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="right side">
|
||||
<OptionalInput v-model:checked="snappingEnabled" @update:checked="comingSoon(200)" :icon="'Snapping'" title="Snapping" />
|
||||
<PopoverButton>
|
||||
<h3>Snapping</h3>
|
||||
<p>More snapping options will be here</p>
|
||||
</PopoverButton>
|
||||
|
||||
<Separator :type="SeparatorType.Unrelated" />
|
||||
|
||||
<OptionalInput v-model:checked="gridEnabled" @update:checked="comingSoon(318)" :icon="'Grid'" title="Grid" />
|
||||
<PopoverButton>
|
||||
<h3>Grid</h3>
|
||||
<p>More grid options will be here</p>
|
||||
</PopoverButton>
|
||||
|
||||
<Separator :type="SeparatorType.Unrelated" />
|
||||
|
||||
<OptionalInput v-model:checked="overlaysEnabled" @update:checked="comingSoon(99)" :icon="'Overlays'" title="Overlays" />
|
||||
<PopoverButton>
|
||||
<h3>Overlays</h3>
|
||||
<p>More overlays options will be here</p>
|
||||
</PopoverButton>
|
||||
|
||||
<Separator :type="SeparatorType.Unrelated" />
|
||||
|
||||
<RadioInput :entries="viewModeEntries" v-model:selectedIndex="viewModeIndex" />
|
||||
<PopoverButton>
|
||||
<h3>View Mode</h3>
|
||||
<p>More view mode options will be here</p>
|
||||
</PopoverButton>
|
||||
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
|
||||
<NumberInput @update:value="setRotation" v-model:value="documentRotation" :step="15" :unit="`°`" ref="rotation" />
|
||||
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
|
||||
<IconButton :action="() => this.$refs.zoom.onIncrement(IncrementDirection.Increase)" :icon="'ZoomIn'" :size="24" title="Zoom In" />
|
||||
<IconButton :action="() => this.$refs.zoom.onIncrement(IncrementDirection.Decrease)" :icon="'ZoomOut'" :size="24" title="Zoom Out" />
|
||||
<IconButton :action="() => this.$refs.zoom.updateValue(100)" :icon="'ZoomReset'" :size="24" title="Zoom to 100%" />
|
||||
|
||||
<Separator :type="SeparatorType.Related" />
|
||||
|
||||
<NumberInput
|
||||
v-model:value="documentZoom"
|
||||
@update:value="setZoom"
|
||||
:min="0.000001"
|
||||
:max="1000000"
|
||||
:step="1.25"
|
||||
:stepIsMultiplier="true"
|
||||
:unit="`%`"
|
||||
:displayDecimalPlaces="4"
|
||||
ref="zoom"
|
||||
/>
|
||||
</div>
|
||||
</LayoutRow>
|
||||
<LayoutRow :class="'shelf-and-viewport'">
|
||||
<LayoutCol :class="'shelf'">
|
||||
<div class="tools">
|
||||
<ShelfItemInput icon="LayoutSelectTool" title="Select Tool (V)" :active="activeTool === 'Select'" :action="() => selectTool('Select')" />
|
||||
<ShelfItemInput icon="LayoutCropTool" title="Crop Tool" :active="activeTool === 'Crop'" :action="() => comingSoon(289) && selectTool('Crop')" />
|
||||
<ShelfItemInput icon="LayoutNavigateTool" title="Navigate Tool (Z)" :active="activeTool === 'Navigate'" :action="() => comingSoon(155) && selectTool('Navigate')" />
|
||||
<ShelfItemInput icon="LayoutEyedropperTool" title="Eyedropper Tool (I)" :active="activeTool === 'Eyedropper'" :action="() => selectTool('Eyedropper')" />
|
||||
|
||||
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
||||
|
||||
<ShelfItemInput icon="ParametricTextTool" title="Text Tool (T)" :active="activeTool === 'Text'" :action="() => comingSoon(153) && selectTool('Text')" />
|
||||
<ShelfItemInput icon="ParametricFillTool" title="Fill Tool (F)" :active="activeTool === 'Fill'" :action="() => selectTool('Fill')" />
|
||||
<ShelfItemInput icon="ParametricGradientTool" title="Gradient Tool (H)" :active="activeTool === 'Gradient'" :action="() => comingSoon() && selectTool('Gradient')" />
|
||||
|
||||
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
||||
|
||||
<ShelfItemInput icon="RasterBrushTool" title="Brush Tool (B)" :active="activeTool === 'Brush'" :action="() => comingSoon() && selectTool('Brush')" />
|
||||
<ShelfItemInput icon="RasterHealTool" title="Heal Tool (J)" :active="activeTool === 'Heal'" :action="() => comingSoon() && selectTool('Heal')" />
|
||||
<ShelfItemInput icon="RasterCloneTool" title="Clone Tool (C)" :active="activeTool === 'Clone'" :action="() => comingSoon() && selectTool('Clone')" />
|
||||
<ShelfItemInput icon="RasterPatchTool" title="Patch Tool" :active="activeTool === 'Patch'" :action="() => comingSoon() && selectTool('Patch')" />
|
||||
<ShelfItemInput icon="RasterBlurSharpenTool" title="Detail Tool (D)" :active="activeTool === 'BlurSharpen'" :action="() => comingSoon() && selectTool('BlurSharpen')" />
|
||||
<ShelfItemInput icon="RasterRelightTool" title="Relight Tool (O)" :active="activeTool === 'Relight'" :action="() => comingSoon() && selectTool('Relight')" />
|
||||
|
||||
<Separator :type="SeparatorType.Section" :direction="SeparatorDirection.Vertical" />
|
||||
|
||||
<ShelfItemInput icon="VectorPathTool" title="Path Tool (A)" :active="activeTool === 'Path'" :action="() => comingSoon(82) && selectTool('Path')" />
|
||||
<ShelfItemInput icon="VectorPenTool" title="Pen Tool (P)" :active="activeTool === 'Pen'" :action="() => selectTool('Pen')" />
|
||||
<ShelfItemInput icon="VectorFreehandTool" title="Freehand Tool (N)" :active="activeTool === 'Freehand'" :action="() => comingSoon() && selectTool('Freehand')" />
|
||||
<ShelfItemInput icon="VectorSplineTool" title="Spline Tool" :active="activeTool === 'Spline'" :action="() => comingSoon() && selectTool('Spline')" />
|
||||
<ShelfItemInput icon="VectorLineTool" title="Line Tool (L)" :active="activeTool === 'Line'" :action="() => selectTool('Line')" />
|
||||
<ShelfItemInput icon="VectorRectangleTool" title="Rectangle Tool (M)" :active="activeTool === 'Rectangle'" :action="() => selectTool('Rectangle')" />
|
||||
<ShelfItemInput icon="VectorEllipseTool" title="Ellipse Tool (E)" :active="activeTool === 'Ellipse'" :action="() => selectTool('Ellipse')" />
|
||||
<ShelfItemInput icon="VectorShapeTool" title="Shape Tool (Y)" :active="activeTool === 'Shape'" :action="() => selectTool('Shape')" />
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="working-colors">
|
||||
<SwatchPairInput />
|
||||
<div class="swap-and-reset">
|
||||
<IconButton :action="swapWorkingColors" :icon="'Swap'" title="Swap (Shift+X)" :size="16" />
|
||||
<IconButton :action="resetWorkingColors" :icon="'ResetColors'" title="Reset (Ctrl+Shift+X)" :size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</LayoutCol>
|
||||
<LayoutCol :class="'viewport'">
|
||||
<LayoutRow :class="'bar-area'">
|
||||
<CanvasRuler :origin="0" :majorMarkSpacing="100" :direction="RulerDirection.Horizontal" :class="'top-ruler'" />
|
||||
</LayoutRow>
|
||||
<LayoutRow :class="'canvas-area'">
|
||||
<LayoutCol :class="'bar-area'">
|
||||
<CanvasRuler :origin="0" :majorMarkSpacing="100" :direction="RulerDirection.Vertical" />
|
||||
</LayoutCol>
|
||||
<LayoutCol :class="'canvas-area'">
|
||||
<div class="canvas" @mousedown="canvasMouseDown" @mouseup="canvasMouseUp" @mousemove="canvasMouseMove" ref="canvas">
|
||||
<svg v-html="viewportSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
|
||||
</div>
|
||||
</LayoutCol>
|
||||
<LayoutCol :class="'bar-area'">
|
||||
<PersistentScrollbar :direction="ScrollbarDirection.Vertical" :class="'right-scrollbar'" />
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
<LayoutRow :class="'bar-area'">
|
||||
<PersistentScrollbar :direction="ScrollbarDirection.Horizontal" :class="'bottom-scrollbar'" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.document {
|
||||
height: 100%;
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.side {
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.shelf-and-viewport {
|
||||
.shelf {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.working-colors .swap-and-reset {
|
||||
font-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
flex: 1 1 100%;
|
||||
|
||||
.canvas-area {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.bar-area {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.top-ruler {
|
||||
padding-left: 16px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.right-scrollbar {
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
.bottom-scrollbar {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
background: var(--color-1-nearblack);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// Allows the SVG to be placed at explicit integer values of width and height to prevent non-pixel-perfect SVG scaling
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
svg {
|
||||
background: #ffffff;
|
||||
position: absolute;
|
||||
// Fallback values if JS hasn't set these to integers yet
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { makeModifiersBitfield } from "@/utilities/input";
|
||||
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, ExportDocument, SetCanvasZoom, SetCanvasRotation } from "@/utilities/response-handler";
|
||||
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
||||
import comingSoon from "@/utilities/coming-soon";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.vue";
|
||||
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
import ShelfItemInput from "@/components/widgets/inputs/ShelfItemInput.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
import PersistentScrollbar, { ScrollbarDirection } from "@/components/widgets/scrollbars/PersistentScrollbar.vue";
|
||||
import CanvasRuler, { RulerDirection } from "@/components/widgets/rulers/CanvasRuler.vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
|
||||
import RadioInput, { RadioEntries } from "@/components/widgets/inputs/RadioInput.vue";
|
||||
import NumberInput, { IncrementDirection } from "@/components/widgets/inputs/NumberInput.vue";
|
||||
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue";
|
||||
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
|
||||
import ToolOptions from "@/components/widgets/options/ToolOptions.vue";
|
||||
import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
|
||||
const documentModeEntries: SectionsOfMenuListEntries = [
|
||||
[
|
||||
{ label: "Design Mode", icon: "ViewportDesignMode" },
|
||||
{ label: "Select Mode", icon: "ViewportSelectMode", action: () => comingSoon(330) },
|
||||
{ label: "Guide Mode", icon: "ViewportGuideMode", action: () => comingSoon(331) },
|
||||
],
|
||||
];
|
||||
const viewModeEntries: RadioEntries = [
|
||||
{ value: "normal", icon: "ViewModeNormal", tooltip: "View Mode: Normal" },
|
||||
{ value: "outline", icon: "ViewModeOutline", tooltip: "View Mode: Outline", action: () => comingSoon(319) },
|
||||
{ value: "pixels", icon: "ViewModePixels", tooltip: "View Mode: Pixels", action: () => comingSoon(320) },
|
||||
];
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
export default defineComponent({
|
||||
methods: {
|
||||
async viewportResize() {
|
||||
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`;
|
||||
|
||||
const { viewport_resize } = await wasm;
|
||||
viewport_resize(width, height);
|
||||
},
|
||||
async canvasMouseDown(e: MouseEvent) {
|
||||
const { on_mouse_down } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_mouse_down(e.offsetX, e.offsetY, e.buttons, modifiers);
|
||||
},
|
||||
async canvasMouseUp(e: MouseEvent) {
|
||||
const { on_mouse_up } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_mouse_up(e.offsetX, e.offsetY, e.buttons, modifiers);
|
||||
},
|
||||
async canvasMouseMove(e: MouseEvent) {
|
||||
const { on_mouse_move } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_mouse_move(e.offsetX, e.offsetY, modifiers);
|
||||
},
|
||||
async canvasMouseScroll(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
const { on_mouse_scroll } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_mouse_scroll(e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
},
|
||||
async setZoom(newZoom: number) {
|
||||
const { set_zoom } = await wasm;
|
||||
set_zoom(newZoom / 100);
|
||||
},
|
||||
async setRotation(newRotation: number) {
|
||||
const { set_rotation } = await wasm;
|
||||
set_rotation(newRotation * (Math.PI / 180));
|
||||
},
|
||||
async selectTool(toolName: string) {
|
||||
const { select_tool } = await wasm;
|
||||
select_tool(toolName);
|
||||
},
|
||||
async swapWorkingColors() {
|
||||
const { swap_colors } = await wasm;
|
||||
swap_colors();
|
||||
},
|
||||
async resetWorkingColors() {
|
||||
const { reset_colors } = await wasm;
|
||||
reset_colors();
|
||||
},
|
||||
download(filename: string, fileData: string) {
|
||||
const svgBlob = new Blob([fileData], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
const element = document.createElement("a");
|
||||
|
||||
element.href = svgUrl;
|
||||
element.setAttribute("download", filename);
|
||||
element.style.display = "none";
|
||||
|
||||
element.click();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
registerResponseHandler(ResponseType.UpdateCanvas, (responseData: Response) => {
|
||||
const updateData = responseData as UpdateCanvas;
|
||||
if (updateData) this.viewportSvg = updateData.document;
|
||||
});
|
||||
registerResponseHandler(ResponseType.ExportDocument, (responseData: Response) => {
|
||||
const updateData = responseData as ExportDocument;
|
||||
if (updateData) this.download("canvas.svg", updateData.document);
|
||||
});
|
||||
registerResponseHandler(ResponseType.SetActiveTool, (responseData: Response) => {
|
||||
const toolData = responseData as SetActiveTool;
|
||||
if (toolData) this.activeTool = toolData.tool_name;
|
||||
});
|
||||
registerResponseHandler(ResponseType.SetCanvasZoom, (responseData: Response) => {
|
||||
const updateData = responseData as SetCanvasZoom;
|
||||
if (updateData) {
|
||||
this.documentZoom = updateData.new_zoom * 100;
|
||||
}
|
||||
});
|
||||
registerResponseHandler(ResponseType.SetCanvasRotation, (responseData: Response) => {
|
||||
const updateData = responseData as SetCanvasRotation;
|
||||
if (updateData) {
|
||||
const newRotation = updateData.new_radians * (180 / Math.PI);
|
||||
this.documentRotation = (360 + (newRotation % 360)) % 360;
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Move event listeners to `main.ts`
|
||||
const canvas = this.$refs.canvas as HTMLDivElement;
|
||||
canvas.addEventListener("wheel", this.canvasMouseScroll, { passive: false });
|
||||
|
||||
window.addEventListener("resize", () => this.viewportResize());
|
||||
window.addEventListener("DOMContentLoaded", () => this.viewportResize());
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
viewportSvg: "",
|
||||
canvasSvgWidth: "100%",
|
||||
canvasSvgHeight: "100%",
|
||||
activeTool: "Select",
|
||||
documentModeEntries,
|
||||
viewModeEntries,
|
||||
documentModeSelectionIndex: 0,
|
||||
viewModeIndex: 0,
|
||||
snappingEnabled: true,
|
||||
gridEnabled: true,
|
||||
overlaysEnabled: true,
|
||||
documentRotation: 0,
|
||||
documentZoom: 100,
|
||||
IncrementDirection,
|
||||
MenuDirection,
|
||||
SeparatorDirection,
|
||||
ScrollbarDirection,
|
||||
RulerDirection,
|
||||
SeparatorType,
|
||||
comingSoon,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
LayoutRow,
|
||||
LayoutCol,
|
||||
SwatchPairInput,
|
||||
ShelfItemInput,
|
||||
Separator,
|
||||
PersistentScrollbar,
|
||||
CanvasRuler,
|
||||
IconButton,
|
||||
PopoverButton,
|
||||
RadioInput,
|
||||
NumberInput,
|
||||
DropdownInput,
|
||||
OptionalInput,
|
||||
ToolOptions,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
369
frontend/src/components/panels/LayerTree.vue
Normal file
369
frontend/src/components/panels/LayerTree.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<LayoutCol :class="'layer-tree-panel'">
|
||||
<LayoutRow :class="'options-bar'">
|
||||
<DropdownInput v-model:selectedIndex="blendModeSelectedIndex" @update:selectedIndex="setLayerBlendMode" :menuEntries="blendModeEntries" :disabled="blendModeDropdownDisabled" />
|
||||
|
||||
<Separator :type="SeparatorType.Related" />
|
||||
|
||||
<NumberInput v-model:value="opacity" @update:value="setLayerOpacity" :min="0" :max="100" :unit="`%`" :displayDecimalPlaces="2" :label="'Opacity'" :disabled="opacityNumberInputDisabled" />
|
||||
|
||||
<Separator :type="SeparatorType.Related" />
|
||||
|
||||
<PopoverButton>
|
||||
<h3>Compositing Options</h3>
|
||||
<p>More blend and compositing options will be here</p>
|
||||
</PopoverButton>
|
||||
</LayoutRow>
|
||||
<LayoutRow :class="'layer-tree scrollable-y'">
|
||||
<LayoutCol :class="'list'" @click="deselectAllLayers">
|
||||
<div class="layer-row" v-for="layer in layers" :key="layer.path">
|
||||
<div class="layer-visibility">
|
||||
<IconButton
|
||||
:action="(e) => (toggleLayerVisibility(layer.path), e.stopPropagation())"
|
||||
:icon="layer.visible ? 'EyeVisible' : 'EyeHidden'"
|
||||
:size="24"
|
||||
:title="layer.visible ? 'Visible' : 'Hidden'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="layer"
|
||||
:class="{ selected: layer.layer_data.selected }"
|
||||
@click.shift.exact.stop="handleShiftClick(layer)"
|
||||
@click.ctrl.exact.stop="handleControlClick(layer)"
|
||||
@click.alt.exact.stop="handleControlClick(layer)"
|
||||
@click.exact.stop="handleClick(layer)"
|
||||
>
|
||||
<div class="layer-thumbnail" v-html="layer.thumbnail"></div>
|
||||
<div class="layer-type-icon">
|
||||
<IconLabel :icon="'NodeTypePath'" title="Path" />
|
||||
</div>
|
||||
<div class="layer-name">
|
||||
<span>{{ layer.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layer-tree-panel {
|
||||
min-height: 0;
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
align-items: center;
|
||||
|
||||
.dropdown-input {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.dropdown-input,
|
||||
.number-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-tree {
|
||||
.layer-row {
|
||||
display: flex;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
margin: 0 8px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.layer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--color-5-dullgray);
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-left: 4px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.selected {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
& + .layer-row {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 64px;
|
||||
height: 100%;
|
||||
background: white;
|
||||
|
||||
svg {
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-type-icon {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { ResponseType, registerResponseHandler, Response, BlendMode, ExpandFolder, UpdateLayer, LayerPanelEntry } from "@/utilities/response-handler";
|
||||
import { SeparatorType } from "@/components/widgets/widgets";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
|
||||
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
|
||||
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue";
|
||||
import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
const blendModeEntries: SectionsOfMenuListEntries = [
|
||||
[{ label: "Normal", value: BlendMode.Normal }],
|
||||
[
|
||||
{ label: "Multiply", value: BlendMode.Multiply },
|
||||
{ label: "Darken", value: BlendMode.Darken },
|
||||
{ label: "Color Burn", value: BlendMode.ColorBurn },
|
||||
// { label: "Linear Burn", value: "" }, // Not supported by SVG
|
||||
// { label: "Darker Color", value: "" }, // Not supported by SVG
|
||||
],
|
||||
[
|
||||
{ label: "Screen", value: BlendMode.Screen },
|
||||
{ label: "Lighten", value: BlendMode.Lighten },
|
||||
{ label: "Color Dodge", value: BlendMode.ColorDodge },
|
||||
// { label: "Linear Dodge (Add)", value: "" }, // Not supported by SVG
|
||||
// { label: "Lighter Color", value: "" }, // Not supported by SVG
|
||||
],
|
||||
[
|
||||
{ label: "Overlay", value: BlendMode.Overlay },
|
||||
{ label: "Soft Light", value: BlendMode.SoftLight },
|
||||
{ label: "Hard Light", value: BlendMode.HardLight },
|
||||
// { label: "Vivid Light", value: "" }, // Not supported by SVG
|
||||
// { label: "Linear Light", value: "" }, // Not supported by SVG
|
||||
// { label: "Pin Light", value: "" }, // Not supported by SVG
|
||||
// { label: "Hard Mix", value: "" }, // Not supported by SVG
|
||||
],
|
||||
[
|
||||
{ label: "Difference", value: BlendMode.Difference },
|
||||
{ label: "Exclusion", value: BlendMode.Exclusion },
|
||||
// { label: "Subtract", value: "" }, // Not supported by SVG
|
||||
// { label: "Divide", value: "" }, // Not supported by SVG
|
||||
],
|
||||
[
|
||||
{ label: "Hue", value: BlendMode.Hue },
|
||||
{ label: "Saturation", value: BlendMode.Saturation },
|
||||
{ label: "Color", value: BlendMode.Color },
|
||||
{ label: "Luminosity", value: BlendMode.Luminosity },
|
||||
],
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
props: {},
|
||||
methods: {
|
||||
async toggleLayerVisibility(path: BigUint64Array) {
|
||||
const { toggle_layer_visibility } = await wasm;
|
||||
toggle_layer_visibility(path);
|
||||
},
|
||||
async setLayerBlendMode() {
|
||||
const blendMode = this.blendModeEntries.flat()[this.blendModeSelectedIndex].value as BlendMode;
|
||||
if (blendMode) {
|
||||
const { set_blend_mode_for_selected_layers } = await wasm;
|
||||
set_blend_mode_for_selected_layers(blendMode);
|
||||
}
|
||||
},
|
||||
async setLayerOpacity() {
|
||||
const { set_opacity_for_selected_layers } = await wasm;
|
||||
set_opacity_for_selected_layers(this.opacity);
|
||||
},
|
||||
async handleControlClick(clickedLayer: LayerPanelEntry) {
|
||||
const index = this.layers.indexOf(clickedLayer);
|
||||
clickedLayer.layer_data.selected = !clickedLayer.layer_data.selected;
|
||||
|
||||
this.selectionRangeEndLayer = undefined;
|
||||
this.selectionRangeStartLayer =
|
||||
this.layers.slice(index).filter((layer) => layer.layer_data.selected)[0] ||
|
||||
this.layers
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.filter((layer) => layer.layer_data.selected)[0];
|
||||
|
||||
this.sendSelectedLayers();
|
||||
},
|
||||
async handleShiftClick(clickedLayer: LayerPanelEntry) {
|
||||
// The two paths of the range are stored in selectionRangeStartLayer and selectionRangeEndLayer
|
||||
// So for a new Shift+Click, select all layers between selectionRangeStartLayer and selectionRangeEndLayer (stored in previous Shift+Click)
|
||||
this.clearSelection();
|
||||
|
||||
this.selectionRangeEndLayer = clickedLayer;
|
||||
if (!this.selectionRangeStartLayer) this.selectionRangeStartLayer = clickedLayer;
|
||||
this.fillSelectionRange(this.selectionRangeStartLayer, this.selectionRangeEndLayer, true);
|
||||
|
||||
this.sendSelectedLayers();
|
||||
},
|
||||
async handleClick(clickedLayer: LayerPanelEntry) {
|
||||
this.selectionRangeStartLayer = clickedLayer;
|
||||
this.selectionRangeEndLayer = clickedLayer;
|
||||
|
||||
this.clearSelection();
|
||||
clickedLayer.layer_data.selected = true;
|
||||
|
||||
this.sendSelectedLayers();
|
||||
},
|
||||
async deselectAllLayers() {
|
||||
this.selectionRangeStartLayer = undefined;
|
||||
this.selectionRangeEndLayer = undefined;
|
||||
|
||||
const { deselect_all_layers } = await wasm;
|
||||
deselect_all_layers();
|
||||
},
|
||||
async fillSelectionRange(start: LayerPanelEntry, end: LayerPanelEntry, selected = true) {
|
||||
const startIndex = this.layers.findIndex((layer) => layer.path.join() === start.path.join());
|
||||
const endIndex = this.layers.findIndex((layer) => layer.path.join() === end.path.join());
|
||||
const [min, max] = [startIndex, endIndex].sort();
|
||||
|
||||
if (min !== -1) {
|
||||
for (let i = min; i <= max; i += 1) {
|
||||
this.layers[i].layer_data.selected = selected;
|
||||
}
|
||||
}
|
||||
},
|
||||
async clearSelection() {
|
||||
this.layers.forEach((layer) => {
|
||||
layer.layer_data.selected = false;
|
||||
});
|
||||
},
|
||||
async sendSelectedLayers() {
|
||||
const paths = this.layers.filter((layer) => layer.layer_data.selected).map((layer) => layer.path);
|
||||
|
||||
const length = paths.reduce((acc, cur) => acc + cur.length, 0) + paths.length - 1;
|
||||
const output = new BigUint64Array(length);
|
||||
|
||||
let i = 0;
|
||||
paths.forEach((path, index) => {
|
||||
output.set(path, i);
|
||||
i += path.length;
|
||||
if (index < paths.length) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
output[i] = (1n << 64n) - 1n;
|
||||
}
|
||||
i += 1;
|
||||
});
|
||||
const { select_layers } = await wasm;
|
||||
select_layers(output);
|
||||
},
|
||||
setBlendModeForSelectedLayers() {
|
||||
const selected = this.layers.filter((layer) => layer.layer_data.selected);
|
||||
|
||||
if (selected.length < 1) {
|
||||
this.blendModeSelectedIndex = 0;
|
||||
this.blendModeDropdownDisabled = true;
|
||||
return;
|
||||
}
|
||||
this.blendModeDropdownDisabled = false;
|
||||
|
||||
const firstEncounteredBlendMode = selected[0].blend_mode;
|
||||
const allBlendModesAlike = !selected.find((layer) => layer.blend_mode !== firstEncounteredBlendMode);
|
||||
|
||||
if (allBlendModesAlike) {
|
||||
this.blendModeSelectedIndex = this.blendModeEntries.flat().findIndex((entry) => entry.value === firstEncounteredBlendMode);
|
||||
} else {
|
||||
// Display a dash when they are not all the same value
|
||||
this.blendModeSelectedIndex = NaN;
|
||||
}
|
||||
},
|
||||
setOpacityForSelectedLayers() {
|
||||
const selected = this.layers.filter((layer) => layer.layer_data.selected);
|
||||
|
||||
if (selected.length < 1) {
|
||||
this.opacity = 100;
|
||||
this.opacityNumberInputDisabled = true;
|
||||
return;
|
||||
}
|
||||
this.opacityNumberInputDisabled = false;
|
||||
|
||||
const firstEncounteredOpacity = selected[0].opacity;
|
||||
const allOpacitiesAlike = !selected.find((layer) => layer.opacity !== firstEncounteredOpacity);
|
||||
|
||||
if (allOpacitiesAlike) {
|
||||
this.opacity = firstEncounteredOpacity;
|
||||
} else {
|
||||
// Display a dash when they are not all the same value
|
||||
this.opacity = NaN;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
registerResponseHandler(ResponseType.ExpandFolder, (responseData: Response) => {
|
||||
const expandData = responseData as ExpandFolder;
|
||||
if (expandData) {
|
||||
const responsePath = expandData.path;
|
||||
const responseLayers = expandData.children as Array<LayerPanelEntry>;
|
||||
if (responsePath.length > 0) console.error("Non root paths are currently not implemented");
|
||||
|
||||
this.layers = responseLayers;
|
||||
|
||||
this.setBlendModeForSelectedLayers();
|
||||
this.setOpacityForSelectedLayers();
|
||||
}
|
||||
});
|
||||
registerResponseHandler(ResponseType.CollapseFolder, (responseData) => {
|
||||
console.log("CollapseFolder: ", responseData);
|
||||
});
|
||||
registerResponseHandler(ResponseType.UpdateLayer, (responseData) => {
|
||||
const updateData = responseData as UpdateLayer;
|
||||
if (updateData) {
|
||||
const responsePath = updateData.path;
|
||||
const responseLayer = updateData.data;
|
||||
|
||||
const index = this.layers.findIndex((layer: LayerPanelEntry) => {
|
||||
const pathLengthsEqual = responsePath.length === layer.path.length;
|
||||
return pathLengthsEqual && responsePath.every((layer_id, i) => layer_id === layer.path[i]);
|
||||
});
|
||||
if (index >= 0) this.layers[index] = responseLayer;
|
||||
|
||||
this.setBlendModeForSelectedLayers();
|
||||
this.setOpacityForSelectedLayers();
|
||||
}
|
||||
});
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
blendModeEntries,
|
||||
blendModeSelectedIndex: 0,
|
||||
blendModeDropdownDisabled: true,
|
||||
opacityNumberInputDisabled: true,
|
||||
layers: [] as Array<LayerPanelEntry>,
|
||||
selectionRangeStartLayer: undefined as undefined | LayerPanelEntry,
|
||||
selectionRangeEndLayer: undefined as undefined | LayerPanelEntry,
|
||||
opacity: 100,
|
||||
MenuDirection,
|
||||
SeparatorType,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
LayoutRow,
|
||||
LayoutCol,
|
||||
Separator,
|
||||
PopoverButton,
|
||||
NumberInput,
|
||||
IconButton,
|
||||
IconLabel,
|
||||
DropdownInput,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
14
frontend/src/components/panels/Minimap.vue
Normal file
14
frontend/src/components/panels/Minimap.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {},
|
||||
});
|
||||
</script>
|
||||
14
frontend/src/components/panels/Properties.vue
Normal file
14
frontend/src/components/panels/Properties.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {},
|
||||
});
|
||||
</script>
|
||||
67
frontend/src/components/widgets/buttons/IconButton.vue
Normal file
67
frontend/src/components/widgets/buttons/IconButton.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<button class="icon-button" :class="`size-${String(size)}`" @click="action">
|
||||
<IconLabel :icon="icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
vertical-align: top;
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
// The `where` pseduo-class does not contribtue to specificity
|
||||
& + :where(.icon-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.size-12 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.size-16 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.size-24 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&.size-32 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
action: { type: Function, required: true },
|
||||
icon: { type: String, required: true },
|
||||
size: { type: Number, required: true },
|
||||
gapAfter: { type: Boolean, default: false },
|
||||
},
|
||||
components: { IconLabel },
|
||||
});
|
||||
</script>
|
||||
83
frontend/src/components/widgets/buttons/PopoverButton.vue
Normal file
83
frontend/src/components/widgets/buttons/PopoverButton.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="popover-button">
|
||||
<IconButton :action="handleClick" :icon="icon" :size="16" data-hover-menu-spawner />
|
||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Bottom" ref="floatingMenu">
|
||||
<slot></slot>
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.popover-button {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.floating-menu {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
vertical-align: top;
|
||||
background: var(--color-1-nearblack);
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Refactor this and other complicated cases dealing with joined widget margins and border-radius by adding a single standard set of classes: joined-first, joined-inner, and joined-last
|
||||
div[class*="-input"] + & {
|
||||
margin-left: 1px;
|
||||
|
||||
.icon-button {
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
|
||||
export enum PopoverButtonIcon {
|
||||
"DropdownArrow" = "DropdownArrow",
|
||||
"VerticalEllipsis" = "VerticalEllipsis",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
FloatingMenu,
|
||||
IconButton,
|
||||
},
|
||||
props: {
|
||||
action: { type: Function, required: false },
|
||||
icon: { type: String, default: PopoverButtonIcon.DropdownArrow },
|
||||
},
|
||||
methods: {
|
||||
handleClick() {
|
||||
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
|
||||
|
||||
if (this.action) this.action();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
66
frontend/src/components/widgets/buttons/TextButton.vue
Normal file
66
frontend/src/components/widgets/buttons/TextButton.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<button class="text-button" :class="{ emphasized, disabled }" :style="minWidth > 0 ? `min-width: ${minWidth}px` : ''" @click="action">
|
||||
<TextLabel>{{ label }}</TextLabel>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--color-5-dullgray);
|
||||
color: var(--color-e-nearwhite);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.emphasized {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-f-white);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-accent-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-4-dimgray);
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
& + .text-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
action: { type: Function, required: true },
|
||||
label: { type: String, required: true },
|
||||
emphasized: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
minWidth: { type: Number, default: 0 },
|
||||
gapAfter: { type: Boolean, default: false },
|
||||
},
|
||||
components: { TextLabel },
|
||||
});
|
||||
</script>
|
||||
287
frontend/src/components/widgets/floating-menus/ColorPicker.vue
Normal file
287
frontend/src/components/widgets/floating-menus/ColorPicker.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="color-picker">
|
||||
<div class="saturation-picker" ref="saturationPicker" data-picker-action="MoveSaturation" @pointerdown="onPointerDown">
|
||||
<div ref="saturationCursor" class="selection-circle"></div>
|
||||
</div>
|
||||
<div class="hue-picker" ref="huePicker" data-picker-action="MoveHue" @pointerdown="onPointerDown">
|
||||
<div ref="hueCursor" class="selection-pincers"></div>
|
||||
</div>
|
||||
<div class="opacity-picker" ref="opacityPicker" data-picker-action="MoveOpacity" @pointerdown="onPointerDown">
|
||||
<div ref="opacityCursor" class="selection-pincers"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.color-picker {
|
||||
--saturation-picker-hue: #ff0000;
|
||||
--opacity-picker-color: #ff0000;
|
||||
display: flex;
|
||||
|
||||
.saturation-picker {
|
||||
width: 256px;
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--saturation-picker-hue));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.saturation-picker,
|
||||
.hue-picker,
|
||||
.opacity-picker {
|
||||
height: 256px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hue-picker,
|
||||
.opacity-picker {
|
||||
width: 24px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hue-picker {
|
||||
background-blend-mode: screen;
|
||||
background: linear-gradient(to top, #ff0000ff 16.666%, #ff000000 33.333%, #ff000000 66.666%, #ff0000ff 83.333%),
|
||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff 16.666%, #00ff00ff 50%, #00ff0000 66.666%), linear-gradient(to top, #0000ff00 33.333%, #0000ffff 50%, #0000ffff 83.333%, #0000ff00 100%);
|
||||
}
|
||||
|
||||
.opacity-picker {
|
||||
background: linear-gradient(to bottom, var(--opacity-picker-color), transparent);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
||||
linear-gradient(#ffffff, #ffffff);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
position: relative;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-circle {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: 0%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-sizing: border-box;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-pincers {
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent #000000;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent #000000 transparent transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { hsvToRgb, rgbToHsv, isRGB } from "@/utilities/color";
|
||||
import { clamp } from "@/utilities/math";
|
||||
|
||||
const enum ColorPickerState {
|
||||
Idle = "Idle",
|
||||
MoveHue = "MoveHue",
|
||||
MoveOpacity = "MoveOpacity",
|
||||
MoveSaturation = "MoveSaturation",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
color: { type: Object, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
state: ColorPickerState.Idle,
|
||||
// Disable proxy on this object
|
||||
// https://v3.vuejs.org/api/options-data.html#data-2
|
||||
// eslint-disable-next-line vue/no-reserved-keys
|
||||
_: {
|
||||
colorPicker: {
|
||||
color: { h: 0, s: 0, v: 0, a: 1 },
|
||||
hue: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
opacity: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
saturation: {
|
||||
rect: { width: 0, height: 0, top: 0, left: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch("color", this.updateColor, { immediate: true });
|
||||
},
|
||||
unmounted() {
|
||||
this.removeEvents();
|
||||
},
|
||||
methods: {
|
||||
addEvents() {
|
||||
document.addEventListener("pointermove", this.onPointerMove);
|
||||
document.addEventListener("pointerup", this.onPointerUp);
|
||||
},
|
||||
removeEvents() {
|
||||
document.removeEventListener("pointermove", this.onPointerMove);
|
||||
document.removeEventListener("pointerup", this.onPointerUp);
|
||||
},
|
||||
getRef<T>(name: string) {
|
||||
return this.$refs[name] as T;
|
||||
},
|
||||
onPointerDown(e: PointerEvent) {
|
||||
if (!(e.currentTarget instanceof Element)) return;
|
||||
const picker = e.currentTarget.getAttribute("data-picker-action");
|
||||
this.state = (() => {
|
||||
switch (picker) {
|
||||
case "MoveHue":
|
||||
return ColorPickerState.MoveHue;
|
||||
case "MoveOpacity":
|
||||
return ColorPickerState.MoveOpacity;
|
||||
case "MoveSaturation":
|
||||
return ColorPickerState.MoveSaturation;
|
||||
default:
|
||||
return ColorPickerState.Idle;
|
||||
}
|
||||
})();
|
||||
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.addEvents();
|
||||
this.updateRects();
|
||||
this.onPointerMove(e);
|
||||
}
|
||||
},
|
||||
onPointerMove(e: PointerEvent) {
|
||||
const { colorPicker } = this.$data._;
|
||||
|
||||
if (this.state === ColorPickerState.MoveHue) {
|
||||
this.setHuePosition(e.clientY - colorPicker.hue.rect.top);
|
||||
} else if (this.state === ColorPickerState.MoveOpacity) {
|
||||
this.setOpacityPosition(e.clientY - colorPicker.opacity.rect.top);
|
||||
} else if (this.state === ColorPickerState.MoveSaturation) {
|
||||
this.setSaturationPosition(e.clientX - colorPicker.saturation.rect.left, e.clientY - colorPicker.saturation.rect.top);
|
||||
}
|
||||
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.updateHue();
|
||||
this.$emit("update:color", hsvToRgb(colorPicker.color));
|
||||
}
|
||||
},
|
||||
onPointerUp() {
|
||||
if (this.state !== ColorPickerState.Idle) {
|
||||
this.state = ColorPickerState.Idle;
|
||||
this.removeEvents();
|
||||
}
|
||||
},
|
||||
updateRects() {
|
||||
const { colorPicker } = this.$data._;
|
||||
|
||||
const saturationPicker = this.getRef<HTMLDivElement>("saturationPicker");
|
||||
const saturation = saturationPicker.getBoundingClientRect();
|
||||
colorPicker.saturation.rect.width = saturation.width;
|
||||
colorPicker.saturation.rect.height = saturation.height;
|
||||
colorPicker.saturation.rect.left = saturation.left;
|
||||
colorPicker.saturation.rect.top = saturation.top;
|
||||
|
||||
const huePicker = this.getRef<HTMLDivElement>("huePicker");
|
||||
const hue = huePicker.getBoundingClientRect();
|
||||
colorPicker.hue.rect.width = hue.width;
|
||||
colorPicker.hue.rect.height = hue.height;
|
||||
colorPicker.hue.rect.left = hue.left;
|
||||
colorPicker.hue.rect.top = hue.top;
|
||||
|
||||
const opacityPicker = this.getRef<HTMLDivElement>("opacityPicker");
|
||||
const opacity = opacityPicker.getBoundingClientRect();
|
||||
colorPicker.opacity.rect.width = opacity.width;
|
||||
colorPicker.opacity.rect.height = opacity.height;
|
||||
colorPicker.opacity.rect.left = opacity.left;
|
||||
colorPicker.opacity.rect.top = opacity.top;
|
||||
},
|
||||
setSaturationPosition(x: number, y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const saturationCursor = this.getRef<HTMLDivElement>("saturationCursor");
|
||||
const saturationPosition = [clamp(x, 0, colorPicker.saturation.rect.width), clamp(y, 0, colorPicker.saturation.rect.height)];
|
||||
saturationCursor.style.transform = `translate(${saturationPosition[0]}px, ${saturationPosition[1]}px)`;
|
||||
colorPicker.color.s = saturationPosition[0] / colorPicker.saturation.rect.width;
|
||||
colorPicker.color.v = (1 - saturationPosition[1] / colorPicker.saturation.rect.height) * 255;
|
||||
},
|
||||
setHuePosition(y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const hueCursor = this.getRef<HTMLDivElement>("hueCursor");
|
||||
const huePosition = clamp(y, 0, colorPicker.hue.rect.height);
|
||||
hueCursor.style.transform = `translateY(${huePosition}px)`;
|
||||
colorPicker.color.h = clamp(1 - huePosition / colorPicker.hue.rect.height);
|
||||
},
|
||||
setOpacityPosition(y: number) {
|
||||
const { colorPicker } = this.$data._;
|
||||
const opacityCursor = this.getRef<HTMLDivElement>("opacityCursor");
|
||||
const opacityPosition = clamp(y, 0, colorPicker.opacity.rect.height);
|
||||
opacityCursor.style.transform = `translateY(${opacityPosition}px)`;
|
||||
colorPicker.color.a = clamp(1 - opacityPosition / colorPicker.opacity.rect.height);
|
||||
},
|
||||
updateHue() {
|
||||
const { colorPicker } = this.$data._;
|
||||
let color = hsvToRgb({ h: colorPicker.color.h, s: 1, v: 255, a: 1 });
|
||||
this.$el.style.setProperty("--saturation-picker-hue", `rgb(${color.r}, ${color.g}, ${color.b})`);
|
||||
color = hsvToRgb(colorPicker.color);
|
||||
this.$el.style.setProperty("--opacity-picker-color", `rgb(${color.r}, ${color.g}, ${color.b})`);
|
||||
},
|
||||
updateColor() {
|
||||
if (this.state !== ColorPickerState.Idle) return;
|
||||
const { color } = this;
|
||||
if (!isRGB(color)) return;
|
||||
const { colorPicker } = this.$data._;
|
||||
colorPicker.color = rgbToHsv(color);
|
||||
this.updateRects();
|
||||
this.setSaturationPosition(colorPicker.color.s * colorPicker.saturation.rect.width, (1 - colorPicker.color.v / 255) * colorPicker.saturation.rect.height);
|
||||
this.setOpacityPosition((1 - colorPicker.color.a) * colorPicker.opacity.rect.height);
|
||||
this.setHuePosition((1 - colorPicker.color.h) * colorPicker.hue.rect.height);
|
||||
this.updateHue();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
109
frontend/src/components/widgets/floating-menus/DialogModal.vue
Normal file
109
frontend/src/components/widgets/floating-menus/DialogModal.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="dialog-modal">
|
||||
<FloatingMenu :type="MenuType.Dialog" :direction="MenuDirection.Center">
|
||||
<LayoutRow>
|
||||
<LayoutCol :class="'icon-column'">
|
||||
<!-- `dialog.icon` class exists to provide special sizing in CSS to specific icons -->
|
||||
<IconLabel :icon="dialog.icon" :class="dialog.icon.toLowerCase()" />
|
||||
</LayoutCol>
|
||||
<LayoutCol :class="'main-column'">
|
||||
<TextLabel :bold="true" :class="'heading'">{{ dialog.heading }}</TextLabel>
|
||||
<TextLabel :class="'details'">{{ dialog.details }}</TextLabel>
|
||||
<LayoutRow :class="'buttons-row'">
|
||||
<TextButton v-for="(button, index) in dialog.buttons" :key="index" :title="button.tooltip" :action="button.callback" v-bind="button.props" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.dialog-modal {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.dialog {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.floating-menu-container .floating-menu-content {
|
||||
pointer-events: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.icon-column {
|
||||
margin-right: 24px;
|
||||
|
||||
.icon-label {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
&.file,
|
||||
&.copy {
|
||||
width: 60px;
|
||||
|
||||
svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-column {
|
||||
.heading {
|
||||
white-space: pre;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.details {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.buttons-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { dismissDialog } from "@/utilities/dialog";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.vue";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["dialog"],
|
||||
components: {
|
||||
LayoutRow,
|
||||
LayoutCol,
|
||||
FloatingMenu,
|
||||
IconLabel,
|
||||
TextLabel,
|
||||
TextButton,
|
||||
},
|
||||
methods: {
|
||||
dismiss() {
|
||||
dismissDialog();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
373
frontend/src/components/widgets/floating-menus/FloatingMenu.vue
Normal file
373
frontend/src/components/widgets/floating-menus/FloatingMenu.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open || type === MenuType.Dialog" ref="floatingMenu">
|
||||
<div class="tail" v-if="type === MenuType.Popover"></div>
|
||||
<div class="floating-menu-container" ref="floatingMenuContainer">
|
||||
<div class="floating-menu-content" :class="{ 'scrollable-y': scrollable }" ref="floatingMenuContent" :style="floatingMenuContentStyle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.floating-menu {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: flex;
|
||||
// Floating menus begin at a z-index of 1000
|
||||
z-index: 1000;
|
||||
--floating-menu-content-offset: 0;
|
||||
--floating-menu-content-border-radius: 4px;
|
||||
|
||||
&.bottom {
|
||||
--floating-menu-content-border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.tail {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
// Put the tail above the floating menu's shadow
|
||||
z-index: 10;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.floating-menu-container {
|
||||
display: flex;
|
||||
|
||||
.floating-menu-content {
|
||||
background: var(--floating-menu-opacity-color-2-mildblack);
|
||||
box-shadow: var(--floating-menu-shadow) 0 2px 4px;
|
||||
border-radius: var(--floating-menu-content-border-radius);
|
||||
color: var(--color-e-nearwhite);
|
||||
font-size: inherit;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// Draw over the application without being clipped by the containing panel's `overflow: hidden`
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
&.dropdown {
|
||||
&.top {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.left {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.right {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.topleft {
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
&.topright {
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
&.topleft {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
&.topright {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.top.dropdown .floating-menu-container,
|
||||
&.bottom.dropdown .floating-menu-container {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
&.popover {
|
||||
--floating-menu-content-offset: 10px;
|
||||
--floating-menu-content-border-radius: 4px;
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.floating-menu-content {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.top,
|
||||
&.bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.top .tail {
|
||||
border-width: 8px 6px 0 6px;
|
||||
border-color: var(--floating-menu-opacity-color-2-mildblack) transparent transparent transparent;
|
||||
margin-left: -6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&.bottom .tail {
|
||||
border-width: 0 6px 8px 6px;
|
||||
border-color: transparent transparent var(--floating-menu-opacity-color-2-mildblack) transparent;
|
||||
margin-left: -6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.left .tail {
|
||||
border-width: 6px 0 6px 8px;
|
||||
border-color: transparent transparent transparent var(--floating-menu-opacity-color-2-mildblack);
|
||||
margin-top: -6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.right .tail {
|
||||
border-width: 6px 8px 6px 0;
|
||||
border-color: transparent var(--floating-menu-opacity-color-2-mildblack) transparent transparent;
|
||||
margin-top: -6px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&.top .floating-menu-container {
|
||||
justify-content: center;
|
||||
margin-bottom: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.bottom .floating-menu-container {
|
||||
justify-content: center;
|
||||
margin-top: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.left .floating-menu-container {
|
||||
align-items: center;
|
||||
margin-right: var(--floating-menu-content-offset);
|
||||
}
|
||||
|
||||
&.right .floating-menu-container {
|
||||
align-items: center;
|
||||
margin-left: var(--floating-menu-content-offset);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export enum MenuDirection {
|
||||
Top = "Top",
|
||||
Bottom = "Bottom",
|
||||
Left = "Left",
|
||||
Right = "Right",
|
||||
TopLeft = "TopLeft",
|
||||
TopRight = "TopRight",
|
||||
BottomLeft = "BottomLeft",
|
||||
BottomRight = "BottomRight",
|
||||
Center = "Center",
|
||||
}
|
||||
|
||||
export enum MenuType {
|
||||
Popover = "Popover",
|
||||
Dropdown = "Dropdown",
|
||||
Dialog = "Dialog",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
direction: { type: String, default: MenuDirection.Bottom },
|
||||
type: { type: String, required: true },
|
||||
windowEdgeMargin: { type: Number, default: 8 },
|
||||
minWidth: { type: Number, default: 0 },
|
||||
scrollable: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
mouseStillDown: false,
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
};
|
||||
},
|
||||
updated() {
|
||||
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
|
||||
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
|
||||
const workspace = document.querySelector(".workspace-row");
|
||||
|
||||
if (floatingMenuContent && workspace) {
|
||||
const workspaceBounds = workspace.getBoundingClientRect();
|
||||
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
|
||||
|
||||
if (this.direction === MenuDirection.Left || this.direction === MenuDirection.Right) {
|
||||
const topOffset = floatingMenuBounds.top - workspaceBounds.top - this.windowEdgeMargin;
|
||||
if (topOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${-topOffset}px)`;
|
||||
|
||||
const bottomOffset = workspaceBounds.bottom - floatingMenuBounds.bottom - this.windowEdgeMargin;
|
||||
if (bottomOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${bottomOffset}px)`;
|
||||
}
|
||||
|
||||
if (this.direction === MenuDirection.Top || this.direction === MenuDirection.Bottom) {
|
||||
const leftOffset = floatingMenuBounds.left - workspaceBounds.left - this.windowEdgeMargin;
|
||||
if (leftOffset < 0) floatingMenuContainer.style.transform = `translate(${-leftOffset}px, 0)`;
|
||||
|
||||
const rightOffset = workspaceBounds.right - floatingMenuBounds.right - this.windowEdgeMargin;
|
||||
if (rightOffset < 0) floatingMenuContainer.style.transform = `translate(${rightOffset}px, 0)`;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setOpen() {
|
||||
this.open = true;
|
||||
},
|
||||
setClosed() {
|
||||
this.open = false;
|
||||
},
|
||||
isOpen(): boolean {
|
||||
return this.open;
|
||||
},
|
||||
getWidth(callback: (width: number) => void) {
|
||||
this.$nextTick(() => {
|
||||
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
|
||||
const width = floatingMenuContent.clientWidth;
|
||||
|
||||
callback(width);
|
||||
});
|
||||
},
|
||||
disableMinWidth(callback: (minWidth: string) => void) {
|
||||
this.$nextTick(() => {
|
||||
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
|
||||
const initialMinWidth = floatingMenuContent.style.minWidth;
|
||||
floatingMenuContent.style.minWidth = "0";
|
||||
|
||||
callback(initialMinWidth);
|
||||
});
|
||||
},
|
||||
enableMinWidth(minWidth: string) {
|
||||
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
|
||||
floatingMenuContent.style.minWidth = minWidth;
|
||||
},
|
||||
mouseMoveHandler(e: MouseEvent) {
|
||||
const MOUSE_STRAY_DISTANCE = 100;
|
||||
const target = e.target as HTMLElement;
|
||||
const mouseOverFloatingMenuKeepOpen = target && (target.closest("[data-hover-menu-keep-open]") as HTMLElement);
|
||||
const mouseOverFloatingMenuSpawner = target && (target.closest("[data-hover-menu-spawner]") as HTMLElement);
|
||||
// TODO: Simplify the following expression when optional chaining is supported by the build system
|
||||
const mouseOverOwnFloatingMenuSpawner =
|
||||
mouseOverFloatingMenuSpawner && mouseOverFloatingMenuSpawner.parentElement && mouseOverFloatingMenuSpawner.parentElement.contains(this.$refs.floatingMenu as HTMLElement);
|
||||
|
||||
// Swap this open floating menu with the one created by the floating menu spawner being hovered over
|
||||
if (mouseOverFloatingMenuSpawner && !mouseOverOwnFloatingMenuSpawner) {
|
||||
this.setClosed();
|
||||
mouseOverFloatingMenuSpawner.click();
|
||||
}
|
||||
|
||||
// Close the floating menu if the mouse has strayed far enough from its bounds
|
||||
if (this.isMouseEventOutsideFloatingMenu(e, MOUSE_STRAY_DISTANCE) && !mouseOverOwnFloatingMenuSpawner && !mouseOverFloatingMenuKeepOpen) {
|
||||
// TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently
|
||||
// submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
||||
this.setClosed();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const eventIncludesLmb = Boolean(e.buttons & 1);
|
||||
|
||||
// Clean up any messes from lost mouseup events
|
||||
if (!this.open && !eventIncludesLmb) {
|
||||
this.mouseStillDown = false;
|
||||
window.removeEventListener("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
},
|
||||
mouseDownHandler(e: MouseEvent) {
|
||||
// Close the floating menu if the mouse clicked outside the floating menu (but within stray distance)
|
||||
if (this.isMouseEventOutsideFloatingMenu(e)) {
|
||||
this.setClosed();
|
||||
|
||||
// Track if the left mouse button is now down so its later click event can be canceled
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (eventIsForLmb) this.mouseStillDown = true;
|
||||
}
|
||||
},
|
||||
mouseUpHandler(e: MouseEvent) {
|
||||
const eventIsForLmb = e.button === 0;
|
||||
|
||||
if (this.mouseStillDown && eventIsForLmb) {
|
||||
// Clean up self
|
||||
this.mouseStillDown = false;
|
||||
window.removeEventListener("mouseup", this.mouseUpHandler);
|
||||
|
||||
// Prevent the click event from firing, which would normally occur right after this mouseup event
|
||||
window.addEventListener("click", this.clickHandlerCapture, true);
|
||||
}
|
||||
},
|
||||
clickHandlerCapture(e: MouseEvent) {
|
||||
// Stop the click event from reopening this floating menu if the click event targets the floating menu's button
|
||||
e.stopPropagation();
|
||||
|
||||
// Clean up self
|
||||
window.removeEventListener("click", this.clickHandlerCapture, true);
|
||||
},
|
||||
isMouseEventOutsideFloatingMenu(e: MouseEvent, extraDistanceAllowed = 0): boolean {
|
||||
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
|
||||
if (!floatingMenuContent) return true;
|
||||
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
|
||||
|
||||
if (floatingMenuBounds.left - e.clientX >= extraDistanceAllowed) return true;
|
||||
if (e.clientX - floatingMenuBounds.right >= extraDistanceAllowed) return true;
|
||||
if (floatingMenuBounds.top - e.clientY >= extraDistanceAllowed) return true;
|
||||
if (e.clientY - floatingMenuBounds.bottom >= extraDistanceAllowed) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
open(newState: boolean, oldState: boolean) {
|
||||
if (newState && !oldState) {
|
||||
// Close floating menu if mouse strays far enough away
|
||||
window.addEventListener("mousemove", this.mouseMoveHandler);
|
||||
|
||||
// Close floating menu if mouse is outside (but within stray distance)
|
||||
window.addEventListener("mousedown", this.mouseDownHandler);
|
||||
|
||||
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
|
||||
window.addEventListener("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
if (!newState && oldState) {
|
||||
window.removeEventListener("mousemove", this.mouseMoveHandler);
|
||||
window.removeEventListener("mousedown", this.mouseDownHandler);
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
floatingMenuContentStyle(): Partial<CSSStyleDeclaration> {
|
||||
return {
|
||||
minWidth: this.minWidth > 0 ? `${this.minWidth}px` : "",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
285
frontend/src/components/widgets/floating-menus/MenuList.vue
Normal file
285
frontend/src/components/widgets/floating-menus/MenuList.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<FloatingMenu :class="'menu-list'" :direction="direction" :type="MenuType.Dropdown" ref="floatingMenu" :windowEdgeMargin="0" :scrollable="scrollable" data-hover-menu-keep-open>
|
||||
<template v-for="(section, sectionIndex) in menuEntries" :key="sectionIndex">
|
||||
<Separator :type="SeparatorType.List" :direction="SeparatorDirection.Vertical" v-if="sectionIndex > 0" />
|
||||
<div
|
||||
v-for="(entry, entryIndex) in section"
|
||||
:key="entryIndex"
|
||||
class="row"
|
||||
:class="{ open: isMenuEntryOpen(entry), active: entry === activeEntry }"
|
||||
@click="handleEntryClick(entry)"
|
||||
@mouseenter="handleEntryMouseEnter(entry)"
|
||||
@mouseleave="handleEntryMouseLeave(entry)"
|
||||
:data-hover-menu-spawner-extend="entry.children && []"
|
||||
>
|
||||
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :class="'entry-checkbox'" />
|
||||
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" :class="'entry-icon'" />
|
||||
<div v-else-if="drawIcon" class="no-icon" />
|
||||
|
||||
<span class="entry-label">{{ entry.label }}</span>
|
||||
|
||||
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
|
||||
<UserInputLabel v-else-if="entry.shortcut && entry.shortcut.length" :inputKeys="[entry.shortcut]" />
|
||||
|
||||
<div class="submenu-arrow" v-if="entry.children && entry.children.length"></div>
|
||||
<div class="no-submenu-arrow" v-else></div>
|
||||
|
||||
<MenuList
|
||||
v-if="entry.children"
|
||||
:direction="MenuDirection.TopRight"
|
||||
:menuEntries="entry.children"
|
||||
v-bind="{ defaultAction, minWidth, drawIcon, scrollable }"
|
||||
:ref="(ref) => setEntryRefs(entry, ref)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FloatingMenu>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.menu-list {
|
||||
.floating-menu-container .floating-menu-content {
|
||||
padding: 4px 0;
|
||||
position: absolute;
|
||||
min-width: 100%;
|
||||
|
||||
.row {
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
& > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.entry-icon svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.no-icon {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
flex: 1 1 100%;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.entry-checkbox,
|
||||
.entry-icon,
|
||||
.no-icon {
|
||||
margin: 0 4px;
|
||||
|
||||
& + .entry-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-input-label {
|
||||
margin: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 3px 0 3px 6px;
|
||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.no-submenu-arrow {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.submenu-arrow,
|
||||
.no-submenu-arrow {
|
||||
margin-left: 6px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open,
|
||||
&.active {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .entry-checkbox label .checkbox-box {
|
||||
border: 1px solid var(--color-f-white);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
import { keyboardLockApiSupported } from "@/utilities/fullscreen";
|
||||
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
||||
|
||||
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
|
||||
|
||||
export type MenuListEntries = Array<MenuListEntry>;
|
||||
export type SectionsOfMenuListEntries = Array<MenuListEntries>;
|
||||
|
||||
interface MenuListEntryData {
|
||||
value?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
checkbox?: boolean;
|
||||
shortcut?: Array<string>;
|
||||
shortcutRequiresLock?: boolean;
|
||||
action?: Function;
|
||||
children?: SectionsOfMenuListEntries;
|
||||
}
|
||||
|
||||
export type MenuListEntry = MenuListEntryData & { ref?: typeof FloatingMenu | typeof MenuList; checked?: boolean };
|
||||
|
||||
const KEYBOARD_LOCK_USE_FULLSCREEN = "This hotkey is reserved by the browser, but becomes available in fullscreen mode";
|
||||
const KEYBOARD_LOCK_SWITCH_BROWSER = "This hotkey is reserved by the browser, but becomes available in Chrome, Edge, and Opera which support the Keyboard.lock() API";
|
||||
|
||||
const MenuList = defineComponent({
|
||||
inject: ["fullscreen"],
|
||||
props: {
|
||||
direction: { type: String as PropType<MenuDirection>, default: MenuDirection.Bottom },
|
||||
menuEntries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
|
||||
activeEntry: { type: Object as PropType<MenuListEntry>, required: false },
|
||||
defaultAction: { type: Function as PropType<Function | undefined>, required: false },
|
||||
minWidth: { type: Number, default: 0 },
|
||||
drawIcon: { type: Boolean, default: false },
|
||||
scrollable: { type: Boolean, default: false },
|
||||
},
|
||||
methods: {
|
||||
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu) {
|
||||
if (ref) menuEntry.ref = ref;
|
||||
},
|
||||
handleEntryClick(menuEntry: MenuListEntry) {
|
||||
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
|
||||
|
||||
if (menuEntry.checkbox) menuEntry.checked = !menuEntry.checked;
|
||||
|
||||
if (menuEntry.action) menuEntry.action();
|
||||
else if (this.defaultAction) this.defaultAction();
|
||||
|
||||
this.$emit("update:activeEntry", menuEntry);
|
||||
},
|
||||
handleEntryMouseEnter(menuEntry: MenuListEntry) {
|
||||
if (!menuEntry.children || !menuEntry.children.length) return;
|
||||
|
||||
if (menuEntry.ref) menuEntry.ref.setOpen();
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
},
|
||||
handleEntryMouseLeave(menuEntry: MenuListEntry) {
|
||||
if (!menuEntry.children || !menuEntry.children.length) return;
|
||||
|
||||
if (menuEntry.ref) menuEntry.ref.setClosed();
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
},
|
||||
isMenuEntryOpen(menuEntry: MenuListEntry): boolean {
|
||||
if (!menuEntry.children || !menuEntry.children.length) return false;
|
||||
|
||||
if (menuEntry.ref) return menuEntry.ref.isOpen();
|
||||
|
||||
return false;
|
||||
},
|
||||
setOpen() {
|
||||
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
|
||||
},
|
||||
setClosed() {
|
||||
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
|
||||
},
|
||||
isOpen(): boolean {
|
||||
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
|
||||
return Boolean(floatingMenu && floatingMenu.isOpen());
|
||||
},
|
||||
measureAndReportWidth() {
|
||||
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(document as any).fonts.ready.then(() => {
|
||||
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
|
||||
|
||||
// Save open/closed state before forcing open, if necessary, for measurement
|
||||
const initiallyOpen = floatingMenu.isOpen();
|
||||
if (!initiallyOpen) floatingMenu.setOpen();
|
||||
|
||||
floatingMenu.disableMinWidth((initialMinWidth: string) => {
|
||||
floatingMenu.getWidth((width: number) => {
|
||||
floatingMenu.enableMinWidth(initialMinWidth);
|
||||
|
||||
// Restore open/closed state if it was forced open for measurement
|
||||
if (!initiallyOpen) floatingMenu.setClosed();
|
||||
|
||||
this.$emit("width-changed", width);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
menuEntriesWithoutRefs(): Array<Array<MenuListEntryData>> {
|
||||
const { menuEntries } = this;
|
||||
return menuEntries.map((entries) =>
|
||||
entries.map((entry) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { ref, ...entryWithoutRef } = entry;
|
||||
return entryWithoutRef;
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.measureAndReportWidth();
|
||||
},
|
||||
updated() {
|
||||
this.measureAndReportWidth();
|
||||
},
|
||||
watch: {
|
||||
menuEntriesWithoutRefs: {
|
||||
handler() {
|
||||
this.measureAndReportWidth();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
keyboardLockInfoMessage: keyboardLockApiSupported() ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
|
||||
SeparatorDirection,
|
||||
SeparatorType,
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
FloatingMenu,
|
||||
Separator,
|
||||
IconLabel,
|
||||
CheckboxInput,
|
||||
UserInputLabel,
|
||||
},
|
||||
});
|
||||
export default MenuList;
|
||||
</script>
|
||||
104
frontend/src/components/widgets/inputs/CheckboxInput.vue
Normal file
104
frontend/src/components/widgets/inputs/CheckboxInput.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="checkbox-input" :class="{ 'outline-style': outlineStyle }">
|
||||
<input type="checkbox" :id="`checkbox-input-${id}`" :checked="checked" @input="(e) => $emit('update:checked', e.target.checked)" />
|
||||
<label :for="`checkbox-input-${id}`">
|
||||
<div class="checkbox-box">
|
||||
<IconLabel :icon="icon" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.checkbox-input {
|
||||
display: inline-block;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
|
||||
.checkbox-box {
|
||||
display: block;
|
||||
background: var(--color-e-nearwhite);
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .checkbox-box {
|
||||
background: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + label {
|
||||
.checkbox-box {
|
||||
background: var(--color-accent);
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .checkbox-box {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.outline-style label {
|
||||
.checkbox-box {
|
||||
border: 1px solid var(--color-e-nearwhite);
|
||||
padding: 1px;
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .checkbox-box {
|
||||
border: 1px solid var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.outline-style input:checked + label {
|
||||
.checkbox-box {
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
id: `${Math.random()}`.substring(2),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
isChecked() {
|
||||
return this.checked;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
checked: { type: Boolean, required: true },
|
||||
icon: { type: String, default: "Checkmark" },
|
||||
outlineStyle: { type: Boolean, default: false },
|
||||
},
|
||||
components: { IconLabel },
|
||||
});
|
||||
</script>
|
||||
139
frontend/src/components/widgets/inputs/DropdownInput.vue
Normal file
139
frontend/src/components/widgets/inputs/DropdownInput.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="dropdown-input">
|
||||
<div class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px`, disabled: 'disabled' }" @click="clickDropdownBox" data-hover-menu-spawner>
|
||||
<IconLabel :class="'dropdown-icon'" :icon="activeEntry.icon" v-if="activeEntry.icon" />
|
||||
<span>{{ activeEntry.label }}</span>
|
||||
<IconLabel :class="'dropdown-arrow'" :icon="'DropdownArrow'" />
|
||||
</div>
|
||||
<MenuList
|
||||
v-model:active-entry="activeEntry"
|
||||
@update:activeEntry="activeEntryChanged"
|
||||
@width-changed="onWidthChanged"
|
||||
:menuEntries="menuEntries"
|
||||
:direction="MenuDirection.Bottom"
|
||||
:drawIcon="drawIcon"
|
||||
:scrollable="true"
|
||||
ref="menuList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.dropdown-input {
|
||||
position: relative;
|
||||
|
||||
.dropdown-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
background: var(--color-1-nearblack);
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
|
||||
.dropdown-icon {
|
||||
margin: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.dropdown-icon + span {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin: 6px 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
span {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
span {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list .floating-menu-container .floating-menu-content {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
menuEntries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
|
||||
selectedIndex: { type: Number, required: true },
|
||||
drawIcon: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeEntry: this.menuEntries.flat()[this.selectedIndex],
|
||||
MenuDirection,
|
||||
minWidth: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// Called only when `selectedIndex` is changed from outside this component (with v-model)
|
||||
selectedIndex(newSelectedIndex: number) {
|
||||
const entries = this.menuEntries.flat();
|
||||
|
||||
if (!Number.isNaN(newSelectedIndex) && newSelectedIndex >= 0 && newSelectedIndex < entries.length) {
|
||||
this.activeEntry = entries[newSelectedIndex];
|
||||
} else {
|
||||
this.activeEntry = { label: "-" };
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Called only when `activeEntry` is changed from the child MenuList component via user input
|
||||
activeEntryChanged(newActiveEntry: MenuListEntry) {
|
||||
this.$emit("update:selectedIndex", this.menuEntries.flat().indexOf(newActiveEntry));
|
||||
},
|
||||
clickDropdownBox() {
|
||||
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
|
||||
},
|
||||
onWidthChanged(newWidth: number) {
|
||||
this.minWidth = newWidth;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
IconLabel,
|
||||
MenuList,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
184
frontend/src/components/widgets/inputs/MenuBarInput.vue
Normal file
184
frontend/src/components/widgets/inputs/MenuBarInput.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="menu-bar-input">
|
||||
<div class="entry-container">
|
||||
<div @click="handleLogoClick(entry)" class="entry">
|
||||
<IconLabel :icon="'GraphiteLogo'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-container" v-for="entry in menuEntries" :key="entry">
|
||||
<div @click="handleEntryClick(entry)" class="entry" :class="{ open: entry.ref && entry.ref.isOpen() }" data-hover-menu-spawner>
|
||||
<IconLabel :icon="entry.icon" v-if="entry.icon" />
|
||||
<span v-if="entry.label">{{ entry.label }}</span>
|
||||
</div>
|
||||
<MenuList :menuEntries="entry.children" :direction="MenuDirection.Bottom" :minWidth="240" :drawIcon="true" :defaultAction="comingSoon" :ref="(ref) => setEntryRefs(entry, ref)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.menu-bar-input {
|
||||
display: flex;
|
||||
|
||||
.entry-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import comingSoon from "@/utilities/coming-soon";
|
||||
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import { ApplicationPlatform } from "@/components/window/MainWindow.vue";
|
||||
import MenuList, { MenuListEntry, MenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
const menuEntries: MenuListEntries = [
|
||||
{
|
||||
label: "File",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{ label: "New", icon: "File", shortcut: ["Ctrl", "N"], shortcutRequiresLock: true, action: async () => (await wasm).new_document() },
|
||||
{ label: "Open…", shortcut: ["Ctrl", "O"] },
|
||||
{
|
||||
label: "Open Recent",
|
||||
shortcut: ["Ctrl", "⇧", "O"],
|
||||
children: [
|
||||
[{ label: "Reopen Last Closed", shortcut: ["Ctrl", "⇧", "T"], shortcutRequiresLock: true }, { label: "Clear Recently Opened" }],
|
||||
[
|
||||
{ label: "Some Recent File.gdd" },
|
||||
{ label: "Another Recent File.gdd" },
|
||||
{ label: "An Older File.gdd" },
|
||||
{ label: "Some Other Older File.gdd" },
|
||||
{ label: "Yet Another Older File.gdd" },
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: "Close", shortcut: ["Ctrl", "W"], shortcutRequiresLock: true, action: async () => (await wasm).close_active_document_with_confirmation() },
|
||||
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"], action: async () => (await wasm).close_all_documents_with_confirmation() },
|
||||
],
|
||||
[
|
||||
{ label: "Save", shortcut: ["Ctrl", "S"] },
|
||||
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"] },
|
||||
{ label: "Save All", shortcut: ["Ctrl", "Alt", "S"] },
|
||||
{ label: "Auto-Save", checkbox: true, checked: true },
|
||||
],
|
||||
[
|
||||
{ label: "Import…", shortcut: ["Ctrl", "I"] },
|
||||
{ label: "Export…", shortcut: ["Ctrl", "E"], action: async () => (await wasm).export_document() },
|
||||
],
|
||||
[{ label: "Quit", shortcut: ["Ctrl", "Q"] }],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{ label: "Undo", shortcut: ["Ctrl", "Z"], action: async () => (await wasm).undo() },
|
||||
{ label: "Redo", shortcut: ["Ctrl", "⇧", "Z"] },
|
||||
],
|
||||
[
|
||||
{ label: "Cut", shortcut: ["Ctrl", "X"] },
|
||||
{ label: "Copy", icon: "Copy", shortcut: ["Ctrl", "C"] },
|
||||
{ label: "Paste", icon: "Paste", shortcut: ["Ctrl", "V"] },
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Layer",
|
||||
ref: undefined,
|
||||
children: [
|
||||
[
|
||||
{ label: "Select All", shortcut: ["Ctrl", "A"], action: async () => (await wasm).select_all_layers() },
|
||||
{ label: "Deselect All", shortcut: ["Ctrl", "Alt", "A"], action: async () => (await wasm).deselect_all_layers() },
|
||||
{
|
||||
label: "Order",
|
||||
children: [
|
||||
[
|
||||
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers(2147483647) },
|
||||
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => (await wasm).reorder_selected_layers(1) },
|
||||
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => (await wasm).reorder_selected_layers(-1) },
|
||||
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers(-2147483648) },
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Document",
|
||||
ref: undefined,
|
||||
children: [[{ label: "Menu entries coming soon" }]],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
ref: undefined,
|
||||
children: [[{ label: "Menu entries coming soon" }]],
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
ref: undefined,
|
||||
children: [[{ label: "Menu entries coming soon" }]],
|
||||
},
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
methods: {
|
||||
setEntryRefs(menuEntry: MenuListEntry, ref: typeof MenuList) {
|
||||
if (ref) menuEntry.ref = ref;
|
||||
},
|
||||
handleEntryClick(menuEntry: MenuListEntry) {
|
||||
if (menuEntry.ref) menuEntry.ref.setOpen();
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
},
|
||||
handleLogoClick() {
|
||||
window.open("https://www.graphite.design", "_blank");
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ApplicationPlatform,
|
||||
menuEntries,
|
||||
MenuDirection,
|
||||
comingSoon,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
IconLabel,
|
||||
MenuList,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
266
frontend/src/components/widgets/inputs/NumberInput.vue
Normal file
266
frontend/src/components/widgets/inputs/NumberInput.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="number-input" :class="{ disabled }">
|
||||
<input
|
||||
:class="{ 'has-label': label }"
|
||||
:id="`number-input-${id}`"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
v-model="text"
|
||||
@change="onTextChanged()"
|
||||
@keydown.esc="onCancelTextChange"
|
||||
ref="input"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<label v-if="label" :for="`number-input-${id}`">{{ label }}</label>
|
||||
<button v-if="!Number.isNaN(value)" class="arrow left" @click="onIncrement(IncrementDirection.Decrease)"></button>
|
||||
<button v-if="!Number.isNaN(value)" class="arrow right" @click="onIncrement(IncrementDirection.Increase)"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.number-input {
|
||||
width: 80px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
background: var(--color-1-nearblack);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
label {
|
||||
flex: 0 0 auto;
|
||||
cursor: text;
|
||||
line-height: 18px;
|
||||
margin-left: 8px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
margin: 0 8px;
|
||||
padding: 3px 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-e-nearwhite);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
|
||||
&:not(:focus).has-label {
|
||||
text-align: right;
|
||||
padding-left: 4px;
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&::selection {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
text-align: left;
|
||||
|
||||
& + label,
|
||||
& ~ .arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover) .arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
padding: 9px 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: rgba(var(--color-1-nearblack-rgb), 0.75);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
&.right::before {
|
||||
border-color: transparent transparent transparent var(--color-f-white);
|
||||
}
|
||||
|
||||
&.left::after {
|
||||
border-color: transparent var(--color-f-white) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 0;
|
||||
padding-left: 7px;
|
||||
padding-right: 6px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 3px 0 3px 3px;
|
||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
padding-left: 6px;
|
||||
padding-right: 7px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 3px 3px 3px 0;
|
||||
border-color: transparent var(--color-e-nearwhite) transparent transparent;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
label,
|
||||
input {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export enum IncrementDirection {
|
||||
Decrease = "Decrease",
|
||||
Increase = "Increase",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
value: { type: Number, required: true },
|
||||
min: { type: Number, required: false },
|
||||
max: { type: Number, required: false },
|
||||
step: { type: Number, default: 1 },
|
||||
stepIsMultiplier: { type: Boolean, default: false },
|
||||
isInteger: { type: Boolean, default: false },
|
||||
unit: { type: String, default: "" },
|
||||
unitIsHiddenWhenEditing: { type: Boolean, default: true },
|
||||
displayDecimalPlaces: { type: Number, default: 3 },
|
||||
label: { type: String, required: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: `${this.value}${this.unit}`,
|
||||
editing: false,
|
||||
IncrementDirection,
|
||||
id: `${Math.random()}`.substring(2),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onTextFocused() {
|
||||
if (Number.isNaN(this.value)) this.text = "";
|
||||
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
|
||||
else this.text = `${this.value}${this.unit}`;
|
||||
|
||||
this.editing = true;
|
||||
const inputElement = this.$refs.input as HTMLInputElement;
|
||||
// Setting the value directly is required to make `inputElement.select()` work
|
||||
inputElement.value = this.text;
|
||||
inputElement.select();
|
||||
},
|
||||
// Called only when `value` is changed from the <input> element via user input and committed, either with the
|
||||
// enter key (via the `change` event) or when the <input> element is defocused (with the `blur` event binding)
|
||||
onTextChanged() {
|
||||
// The `inputElement.blur()` call at the bottom of this function causes itself to be run again, so this check skips a second run
|
||||
if (!this.editing) return;
|
||||
|
||||
const newValue = parseFloat(this.text);
|
||||
this.updateValue(newValue);
|
||||
|
||||
this.editing = false;
|
||||
const inputElement = this.$refs.input as HTMLElement;
|
||||
inputElement.blur();
|
||||
},
|
||||
onCancelTextChange() {
|
||||
this.updateValue(NaN);
|
||||
|
||||
this.editing = false;
|
||||
const inputElement = this.$refs.input as HTMLElement;
|
||||
inputElement.blur();
|
||||
},
|
||||
onIncrement(direction: IncrementDirection) {
|
||||
if (Number.isNaN(this.value)) return;
|
||||
|
||||
if (this.stepIsMultiplier) {
|
||||
const directionMultiplier = direction === IncrementDirection.Increase ? this.step : 1 / this.step;
|
||||
this.updateValue(this.value * directionMultiplier);
|
||||
} else {
|
||||
const directionAddend = direction === IncrementDirection.Increase ? this.step : -this.step;
|
||||
this.updateValue(this.value + directionAddend);
|
||||
}
|
||||
},
|
||||
updateValue(newValue: number) {
|
||||
let sanitized = newValue;
|
||||
|
||||
const invalid = Number.isNaN(newValue);
|
||||
if (invalid) sanitized = this.value;
|
||||
|
||||
if (this.isInteger) sanitized = Math.round(sanitized);
|
||||
if (typeof this.min === "number" && !Number.isNaN(this.min)) sanitized = Math.max(sanitized, this.min);
|
||||
if (typeof this.max === "number" && !Number.isNaN(this.max)) sanitized = Math.min(sanitized, this.max);
|
||||
|
||||
if (!invalid) this.$emit("update:value", sanitized);
|
||||
|
||||
const roundingPower = 10 ** this.displayDecimalPlaces;
|
||||
const displayValue = Math.round(sanitized * roundingPower) / roundingPower;
|
||||
this.text = `${displayValue}${this.unit}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// Called only when `value` is changed from outside this component (with v-model)
|
||||
value(newValue: number) {
|
||||
if (Number.isNaN(newValue)) {
|
||||
this.text = "-";
|
||||
return;
|
||||
}
|
||||
|
||||
let sanitized = newValue;
|
||||
if (typeof this.min === "number") sanitized = Math.max(sanitized, this.min);
|
||||
if (typeof this.max === "number") sanitized = Math.min(sanitized, this.max);
|
||||
|
||||
const roundingPower = 10 ** this.displayDecimalPlaces;
|
||||
const displayValue = Math.round(sanitized * roundingPower) / roundingPower;
|
||||
this.text = `${displayValue}${this.unit}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const inputElement = this.$refs.input as HTMLInputElement;
|
||||
inputElement.addEventListener("focus", this.onTextFocused);
|
||||
inputElement.addEventListener("blur", this.onTextChanged);
|
||||
},
|
||||
beforeUnmount() {
|
||||
const inputElement = this.$refs.input as HTMLInputElement;
|
||||
inputElement.removeEventListener("focus", this.onTextFocused);
|
||||
inputElement.removeEventListener("blur", this.onTextChanged);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
49
frontend/src/components/widgets/inputs/OptionalInput.vue
Normal file
49
frontend/src/components/widgets/inputs/OptionalInput.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="optional-input">
|
||||
<CheckboxInput :checked="checked" @input="(e) => $emit('update:checked', e.target.checked)" :icon="icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.optional-input {
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-7-middlegray);
|
||||
border-radius: 2px 0 0 2px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + label {
|
||||
border: 1px solid var(--color-accent);
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--color-accent-hover);
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
checked: { type: Boolean, required: true },
|
||||
icon: { type: String, default: "Checkmark" },
|
||||
},
|
||||
components: {
|
||||
CheckboxInput,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
99
frontend/src/components/widgets/inputs/RadioInput.vue
Normal file
99
frontend/src/components/widgets/inputs/RadioInput.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="radio-input" ref="radioInput">
|
||||
<button :class="{ active: index === selectedIndex }" v-for="(entry, index) in entries" :key="index" @click="handleEntryClick(entry)" :title="entry.tooltip">
|
||||
<IconLabel v-if="entry.icon" :icon="entry.icon" />
|
||||
<TextLabel v-if="entry.label">{{ entry.label }}</TextLabel>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.radio-input {
|
||||
button {
|
||||
background: var(--color-5-dullgray);
|
||||
fill: var(--color-e-nearwhite);
|
||||
height: 24px;
|
||||
padding: 0 4px;
|
||||
outline: none;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-f-white);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
& + button {
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-label,
|
||||
.text-label {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.text-label {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||
|
||||
export interface RadioEntryData {
|
||||
value?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
tooltip?: string;
|
||||
action?: Function;
|
||||
}
|
||||
|
||||
export type RadioEntries = Array<RadioEntryData>;
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
entries: { type: Array as PropType<RadioEntries>, required: true },
|
||||
selectedIndex: { type: Number, required: true },
|
||||
},
|
||||
methods: {
|
||||
handleEntryClick(menuEntry: RadioEntryData) {
|
||||
const index = this.entries.indexOf(menuEntry);
|
||||
this.$emit("update:selectedIndex", index);
|
||||
|
||||
if (menuEntry.action) menuEntry.action();
|
||||
},
|
||||
},
|
||||
components: {
|
||||
IconLabel,
|
||||
TextLabel,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
44
frontend/src/components/widgets/inputs/ShelfItemInput.vue
Normal file
44
frontend/src/components/widgets/inputs/ShelfItemInput.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="shelf-item-input" :class="{ active: active }">
|
||||
<IconButton :action="action" :icon="icon" :size="32" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.shelf-item-input {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: unset;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { IconButton },
|
||||
props: {
|
||||
icon: { type: String, required: true },
|
||||
action: { type: Function, required: true },
|
||||
active: { type: Boolean, default: false },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
159
frontend/src/components/widgets/inputs/SwatchPairInput.vue
Normal file
159
frontend/src/components/widgets/inputs/SwatchPairInput.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="swatch-pair">
|
||||
<div class="secondary swatch">
|
||||
<button @click="clickSecondarySwatch" ref="secondaryButton" data-hover-menu-spawner></button>
|
||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="secondarySwatchFloatingMenu">
|
||||
<ColorPicker @update:color="secondaryColorChanged" :color="secondaryColor" />
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
<div class="primary swatch">
|
||||
<button @click="clickPrimarySwatch" ref="primaryButton" data-hover-menu-spawner></button>
|
||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="primarySwatchFloatingMenu">
|
||||
<ColorPicker @update:color="primaryColorChanged" :color="primaryColor" />
|
||||
</FloatingMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.swatch-pair {
|
||||
display: flex;
|
||||
// Reversed order of elements paired with `column-reverse` allows primary to overlap secondary without relying on `z-index`
|
||||
flex-direction: column-reverse;
|
||||
|
||||
.swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 2px;
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
--swatch-color: #ffffff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 2px var(--color-7-middlegray) solid;
|
||||
box-shadow: 0 0 0 2px var(--color-3-darkgray);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
||||
linear-gradient(#ffffff, #ffffff);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu {
|
||||
top: 50%;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { rgbToDecimalRgb, RGB } from "@/utilities/color";
|
||||
import { defineComponent } from "vue";
|
||||
import ColorPicker from "@/components/widgets/floating-menus/ColorPicker.vue";
|
||||
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
import { ResponseType, registerResponseHandler, Response, UpdateWorkingColors } from "@/utilities/response-handler";
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
FloatingMenu,
|
||||
ColorPicker,
|
||||
},
|
||||
props: {},
|
||||
methods: {
|
||||
clickPrimarySwatch() {
|
||||
this.getRef<typeof FloatingMenu>("primarySwatchFloatingMenu").setOpen();
|
||||
this.getRef<typeof FloatingMenu>("secondarySwatchFloatingMenu").setClosed();
|
||||
},
|
||||
|
||||
clickSecondarySwatch() {
|
||||
this.getRef<typeof FloatingMenu>("secondarySwatchFloatingMenu").setOpen();
|
||||
this.getRef<typeof FloatingMenu>("primarySwatchFloatingMenu").setClosed();
|
||||
},
|
||||
|
||||
getRef<T>(name: string) {
|
||||
return this.$refs[name] as T;
|
||||
},
|
||||
|
||||
primaryColorChanged(color: RGB) {
|
||||
this.primaryColor = color;
|
||||
this.updatePrimaryColor();
|
||||
},
|
||||
|
||||
secondaryColorChanged(color: RGB) {
|
||||
this.secondaryColor = color;
|
||||
this.updateSecondaryColor();
|
||||
},
|
||||
|
||||
async updatePrimaryColor() {
|
||||
const { update_primary_color, Color } = await wasm;
|
||||
|
||||
let color = this.primaryColor;
|
||||
const button = this.getRef<HTMLButtonElement>("primaryButton");
|
||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||
|
||||
color = rgbToDecimalRgb(this.primaryColor);
|
||||
update_primary_color(new Color(color.r, color.g, color.b, color.a));
|
||||
},
|
||||
|
||||
async updateSecondaryColor() {
|
||||
const { update_secondary_color, Color } = await wasm;
|
||||
|
||||
let color = this.secondaryColor;
|
||||
const button = this.getRef<HTMLButtonElement>("secondaryButton");
|
||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||
|
||||
color = rgbToDecimalRgb(this.secondaryColor);
|
||||
update_secondary_color(new Color(color.r, color.g, color.b, color.a));
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
MenuDirection,
|
||||
MenuType,
|
||||
primaryColor: { r: 0, g: 0, b: 0, a: 1 },
|
||||
secondaryColor: { r: 255, g: 255, b: 255, a: 1 },
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
registerResponseHandler(ResponseType.UpdateWorkingColors, (responseData: Response) => {
|
||||
const colorData = responseData as UpdateWorkingColors;
|
||||
if (!colorData) return;
|
||||
const { primary, secondary } = colorData;
|
||||
|
||||
this.primaryColor = { r: primary.red, g: primary.green, b: primary.blue, a: primary.alpha };
|
||||
let color = this.primaryColor;
|
||||
let button = this.getRef<HTMLButtonElement>("primaryButton");
|
||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||
|
||||
this.secondaryColor = { r: secondary.red, g: secondary.green, b: secondary.blue, a: secondary.alpha };
|
||||
color = this.secondaryColor;
|
||||
button = this.getRef<HTMLButtonElement>("secondaryButton");
|
||||
button.style.setProperty("--swatch-color", `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`);
|
||||
});
|
||||
|
||||
this.updatePrimaryColor();
|
||||
this.updateSecondaryColor();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
209
frontend/src/components/widgets/labels/IconLabel.vue
Normal file
209
frontend/src/components/widgets/labels/IconLabel.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="icon-label" :class="`size-${String(icons[icon].size)}`">
|
||||
<component :is="icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.icon-label {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
&.size-12 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.size-16 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.size-24 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import LayoutSelectTool from "@/../assets/24px-two-tone/layout-select-tool.svg";
|
||||
import LayoutCropTool from "@/../assets/24px-two-tone/layout-crop-tool.svg";
|
||||
import LayoutNavigateTool from "@/../assets/24px-two-tone/layout-navigate-tool.svg";
|
||||
import LayoutEyedropperTool from "@/../assets/24px-two-tone/layout-eyedropper-tool.svg";
|
||||
import ParametricTextTool from "@/../assets/24px-two-tone/parametric-text-tool.svg";
|
||||
import ParametricFillTool from "@/../assets/24px-two-tone/parametric-fill-tool.svg";
|
||||
import ParametricGradientTool from "@/../assets/24px-two-tone/parametric-gradient-tool.svg";
|
||||
import RasterBrushTool from "@/../assets/24px-two-tone/raster-brush-tool.svg";
|
||||
import RasterHealTool from "@/../assets/24px-two-tone/raster-heal-tool.svg";
|
||||
import RasterCloneTool from "@/../assets/24px-two-tone/raster-clone-tool.svg";
|
||||
import RasterPatchTool from "@/../assets/24px-two-tone/raster-patch-tool.svg";
|
||||
import RasterBlurSharpenTool from "@/../assets/24px-two-tone/raster-detail-tool.svg";
|
||||
import RasterRelightTool from "@/../assets/24px-two-tone/raster-relight-tool.svg";
|
||||
import VectorPathTool from "@/../assets/24px-two-tone/vector-path-tool.svg";
|
||||
import VectorPenTool from "@/../assets/24px-two-tone/vector-pen-tool.svg";
|
||||
import VectorFreehandTool from "@/../assets/24px-two-tone/vector-freehand-tool.svg";
|
||||
import VectorSplineTool from "@/../assets/24px-two-tone/vector-spline-tool.svg";
|
||||
import VectorLineTool from "@/../assets/24px-two-tone/vector-line-tool.svg";
|
||||
import VectorRectangleTool from "@/../assets/24px-two-tone/vector-rectangle-tool.svg";
|
||||
import VectorEllipseTool from "@/../assets/24px-two-tone/vector-ellipse-tool.svg";
|
||||
import VectorShapeTool from "@/../assets/24px-two-tone/vector-shape-tool.svg";
|
||||
|
||||
import AlignLeft from "@/../assets/16px-solid/align-left.svg";
|
||||
import AlignHorizontalCenter from "@/../assets/16px-solid/align-horizontal-center.svg";
|
||||
import AlignRight from "@/../assets/16px-solid/align-right.svg";
|
||||
import AlignTop from "@/../assets/16px-solid/align-top.svg";
|
||||
import AlignVerticalCenter from "@/../assets/16px-solid/align-vertical-center.svg";
|
||||
import AlignBottom from "@/../assets/16px-solid/align-bottom.svg";
|
||||
import FlipHorizontal from "@/../assets/16px-solid/flip-horizontal.svg";
|
||||
import FlipVertical from "@/../assets/16px-solid/flip-vertical.svg";
|
||||
import BooleanUnion from "@/../assets/16px-solid/boolean-union.svg";
|
||||
import BooleanSubtractFront from "@/../assets/16px-solid/boolean-subtract-front.svg";
|
||||
import BooleanSubtractBack from "@/../assets/16px-solid/boolean-subtract-back.svg";
|
||||
import BooleanIntersect from "@/../assets/16px-solid/boolean-intersect.svg";
|
||||
import BooleanDifference from "@/../assets/16px-solid/boolean-difference.svg";
|
||||
import ZoomReset from "@/../assets/16px-solid/zoom-reset.svg";
|
||||
import ZoomIn from "@/../assets/16px-solid/zoom-in.svg";
|
||||
import ZoomOut from "@/../assets/16px-solid/zoom-out.svg";
|
||||
import ViewModeNormal from "@/../assets/16px-solid/view-mode-normal.svg";
|
||||
import ViewModeOutline from "@/../assets/16px-solid/view-mode-outline.svg";
|
||||
import ViewModePixels from "@/../assets/16px-solid/view-mode-pixels.svg";
|
||||
import EyeVisible from "@/../assets/16px-solid/eye-visible.svg";
|
||||
import EyeHidden from "@/../assets/16px-solid/eye-hidden.svg";
|
||||
import GraphiteLogo from "@/../assets/16px-solid/graphite-logo.svg";
|
||||
import File from "@/../assets/16px-solid/file.svg";
|
||||
import Copy from "@/../assets/16px-solid/copy.svg";
|
||||
import Paste from "@/../assets/16px-solid/paste.svg";
|
||||
import ViewportDesignMode from "@/../assets/16px-solid/viewport-design-mode.svg";
|
||||
import ViewportSelectMode from "@/../assets/16px-solid/viewport-select-mode.svg";
|
||||
import ViewportGuideMode from "@/../assets/16px-solid/viewport-guide-mode.svg";
|
||||
|
||||
import Checkmark from "@/../assets/12px-solid/checkmark.svg";
|
||||
import Link from "@/../assets/12px-solid/link.svg";
|
||||
import Grid from "@/../assets/12px-solid/grid.svg";
|
||||
import Overlays from "@/../assets/12px-solid/overlays.svg";
|
||||
import Snapping from "@/../assets/12px-solid/snapping.svg";
|
||||
import Info from "@/../assets/12px-solid/info.svg";
|
||||
import Warning from "@/../assets/12px-solid/warning.svg";
|
||||
import Swap from "@/../assets/12px-solid/swap.svg";
|
||||
import ResetColors from "@/../assets/12px-solid/reset-colors.svg";
|
||||
import DropdownArrow from "@/../assets/12px-solid/dropdown-arrow.svg";
|
||||
import VerticalEllipsis from "@/../assets/12px-solid/vertical-ellipsis.svg";
|
||||
import CloseX from "@/../assets/12px-solid/close-x.svg";
|
||||
import FullscreenEnter from "@/../assets/12px-solid/fullscreen-enter.svg";
|
||||
import FullscreenExit from "@/../assets/12px-solid/fullscreen-exit.svg";
|
||||
import WindowButtonWinMinimize from "@/../assets/12px-solid/window-button-win-minimize.svg";
|
||||
import WindowButtonWinMaximize from "@/../assets/12px-solid/window-button-win-maximize.svg";
|
||||
import WindowButtonWinRestoreDown from "@/../assets/12px-solid/window-button-win-restore-down.svg";
|
||||
import WindowButtonWinClose from "@/../assets/12px-solid/window-button-win-close.svg";
|
||||
|
||||
import MouseHintNone from "@/../assets/16px-two-tone/mouse-hint-none.svg";
|
||||
import MouseHintLMB from "@/../assets/16px-two-tone/mouse-hint-lmb.svg";
|
||||
import MouseHintRMB from "@/../assets/16px-two-tone/mouse-hint-rmb.svg";
|
||||
import MouseHintMMB from "@/../assets/16px-two-tone/mouse-hint-mmb.svg";
|
||||
import MouseHintScrollUp from "@/../assets/16px-two-tone/mouse-hint-scroll-up.svg";
|
||||
import MouseHintScrollDown from "@/../assets/16px-two-tone/mouse-hint-scroll-down.svg";
|
||||
import MouseHintDrag from "@/../assets/16px-two-tone/mouse-hint-drag.svg";
|
||||
import MouseHintLMBDrag from "@/../assets/16px-two-tone/mouse-hint-lmb-drag.svg";
|
||||
import MouseHintRMBDrag from "@/../assets/16px-two-tone/mouse-hint-rmb-drag.svg";
|
||||
import MouseHintMMBDrag from "@/../assets/16px-two-tone/mouse-hint-mmb-drag.svg";
|
||||
|
||||
import NodeTypePath from "@/../assets/24px-full-color/node-type-path.svg";
|
||||
|
||||
const icons = {
|
||||
LayoutSelectTool: { component: LayoutSelectTool, size: 24 },
|
||||
LayoutCropTool: { component: LayoutCropTool, size: 24 },
|
||||
LayoutNavigateTool: { component: LayoutNavigateTool, size: 24 },
|
||||
LayoutEyedropperTool: { component: LayoutEyedropperTool, size: 24 },
|
||||
ParametricTextTool: { component: ParametricTextTool, size: 24 },
|
||||
ParametricFillTool: { component: ParametricFillTool, size: 24 },
|
||||
ParametricGradientTool: { component: ParametricGradientTool, size: 24 },
|
||||
RasterBrushTool: { component: RasterBrushTool, size: 24 },
|
||||
RasterHealTool: { component: RasterHealTool, size: 24 },
|
||||
RasterCloneTool: { component: RasterCloneTool, size: 24 },
|
||||
RasterPatchTool: { component: RasterPatchTool, size: 24 },
|
||||
RasterBlurSharpenTool: { component: RasterBlurSharpenTool, size: 24 },
|
||||
RasterRelightTool: { component: RasterRelightTool, size: 24 },
|
||||
VectorPathTool: { component: VectorPathTool, size: 24 },
|
||||
VectorPenTool: { component: VectorPenTool, size: 24 },
|
||||
VectorFreehandTool: { component: VectorFreehandTool, size: 24 },
|
||||
VectorSplineTool: { component: VectorSplineTool, size: 24 },
|
||||
VectorLineTool: { component: VectorLineTool, size: 24 },
|
||||
VectorRectangleTool: { component: VectorRectangleTool, size: 24 },
|
||||
VectorEllipseTool: { component: VectorEllipseTool, size: 24 },
|
||||
VectorShapeTool: { component: VectorShapeTool, size: 24 },
|
||||
AlignLeft: { component: AlignLeft, size: 16 },
|
||||
AlignHorizontalCenter: { component: AlignHorizontalCenter, size: 16 },
|
||||
AlignRight: { component: AlignRight, size: 16 },
|
||||
AlignTop: { component: AlignTop, size: 16 },
|
||||
AlignVerticalCenter: { component: AlignVerticalCenter, size: 16 },
|
||||
AlignBottom: { component: AlignBottom, size: 16 },
|
||||
FlipHorizontal: { component: FlipHorizontal, size: 16 },
|
||||
FlipVertical: { component: FlipVertical, size: 16 },
|
||||
BooleanUnion: { component: BooleanUnion, size: 16 },
|
||||
BooleanSubtractFront: { component: BooleanSubtractFront, size: 16 },
|
||||
BooleanSubtractBack: { component: BooleanSubtractBack, size: 16 },
|
||||
BooleanIntersect: { component: BooleanIntersect, size: 16 },
|
||||
BooleanDifference: { component: BooleanDifference, size: 16 },
|
||||
ZoomReset: { component: ZoomReset, size: 16 },
|
||||
ZoomIn: { component: ZoomIn, size: 16 },
|
||||
ZoomOut: { component: ZoomOut, size: 16 },
|
||||
ViewModeNormal: { component: ViewModeNormal, size: 16 },
|
||||
ViewModeOutline: { component: ViewModeOutline, size: 16 },
|
||||
ViewModePixels: { component: ViewModePixels, size: 16 },
|
||||
EyeVisible: { component: EyeVisible, size: 16 },
|
||||
EyeHidden: { component: EyeHidden, size: 16 },
|
||||
GraphiteLogo: { component: GraphiteLogo, size: 16 },
|
||||
File: { component: File, size: 16 },
|
||||
Copy: { component: Copy, size: 16 },
|
||||
Paste: { component: Paste, size: 16 },
|
||||
ViewportDesignMode: { component: ViewportDesignMode, size: 16 },
|
||||
ViewportSelectMode: { component: ViewportSelectMode, size: 16 },
|
||||
ViewportGuideMode: { component: ViewportGuideMode, size: 16 },
|
||||
Checkmark: { component: Checkmark, size: 12 },
|
||||
Link: { component: Link, size: 12 },
|
||||
Grid: { component: Grid, size: 12 },
|
||||
Overlays: { component: Overlays, size: 12 },
|
||||
Snapping: { component: Snapping, size: 12 },
|
||||
Info: { component: Info, size: 12 },
|
||||
Warning: { component: Warning, size: 12 },
|
||||
Swap: { component: Swap, size: 12 },
|
||||
ResetColors: { component: ResetColors, size: 12 },
|
||||
DropdownArrow: { component: DropdownArrow, size: 12 },
|
||||
VerticalEllipsis: { component: VerticalEllipsis, size: 12 },
|
||||
CloseX: { component: CloseX, size: 12 },
|
||||
FullscreenEnter: { component: FullscreenEnter, size: 12 },
|
||||
FullscreenExit: { component: FullscreenExit, size: 12 },
|
||||
WindowButtonWinMinimize: { component: WindowButtonWinMinimize, size: 12 },
|
||||
WindowButtonWinMaximize: { component: WindowButtonWinMaximize, size: 12 },
|
||||
WindowButtonWinRestoreDown: { component: WindowButtonWinRestoreDown, size: 12 },
|
||||
WindowButtonWinClose: { component: WindowButtonWinClose, size: 12 },
|
||||
MouseHintNone: { component: MouseHintNone, size: 16 },
|
||||
MouseHintLMB: { component: MouseHintLMB, size: 16 },
|
||||
MouseHintRMB: { component: MouseHintRMB, size: 16 },
|
||||
MouseHintMMB: { component: MouseHintMMB, size: 16 },
|
||||
MouseHintScrollUp: { component: MouseHintScrollUp, size: 16 },
|
||||
MouseHintScrollDown: { component: MouseHintScrollDown, size: 16 },
|
||||
MouseHintDrag: { component: MouseHintDrag, size: 16 },
|
||||
MouseHintLMBDrag: { component: MouseHintLMBDrag, size: 16 },
|
||||
MouseHintRMBDrag: { component: MouseHintRMBDrag, size: 16 },
|
||||
MouseHintMMBDrag: { component: MouseHintMMBDrag, size: 16 },
|
||||
NodeTypePath: { component: NodeTypePath, size: 24 },
|
||||
};
|
||||
|
||||
const components = Object.fromEntries(Object.entries(icons).map(([name, data]) => [name, data.component]));
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
icon: { type: String, required: true },
|
||||
gapAfter: { type: Boolean, default: false },
|
||||
},
|
||||
components,
|
||||
data() {
|
||||
return { icons };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
32
frontend/src/components/widgets/labels/TextLabel.vue
Normal file
32
frontend/src/components/widgets/labels/TextLabel.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<span class="text-label" :class="{ bold, italic }">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.text-label {
|
||||
white-space: nowrap;
|
||||
line-height: 18px;
|
||||
|
||||
&.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
bold: { type: Boolean, default: false },
|
||||
italic: { type: Boolean, default: false },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
155
frontend/src/components/widgets/labels/UserInputLabel.vue
Normal file
155
frontend/src/components/widgets/labels/UserInputLabel.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="user-input-label">
|
||||
<template v-for="(keyGroup, keyGroupIndex) in inputKeys" :key="keyGroupIndex">
|
||||
<span class="group-gap" v-if="keyGroupIndex > 0"></span>
|
||||
<span class="input-key" v-for="inputKey in keyGroup" :key="inputKey" :class="keyCapWidth(inputKey)">
|
||||
{{ inputKey }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="input-mouse" v-if="inputMouse">
|
||||
<IconLabel :icon="mouseInputInteractionToIcon(inputMouse)" />
|
||||
</span>
|
||||
<span class="hint-text" v-if="hasSlotContent">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.user-input-label {
|
||||
height: 100%;
|
||||
margin: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
.group-gap {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.input-key,
|
||||
.input-mouse {
|
||||
& + .input-key,
|
||||
& + .input-mouse {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-key {
|
||||
font-family: "Inconsolata", monospace;
|
||||
text-align: center;
|
||||
color: var(--color-e-nearwhite);
|
||||
border: 1px;
|
||||
box-sizing: border-box;
|
||||
border-style: solid;
|
||||
border-color: var(--color-7-middlegray);
|
||||
border-radius: 4px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.input-key.width-16 {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.input-key.width-24 {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.input-key.width-32 {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.input-key.width-40 {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.input-key.width-48 {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.input-mouse {
|
||||
.bright {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.dim {
|
||||
fill: var(--color-7-middlegray);
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
export enum MouseInputInteraction {
|
||||
"None" = "None",
|
||||
"LMB" = "LMB",
|
||||
"RMB" = "RMB",
|
||||
"MMB" = "MMB",
|
||||
"ScrollUp" = "ScrollUp",
|
||||
"ScrollDown" = "ScrollDown",
|
||||
"Drag" = "Drag",
|
||||
"LMBDrag" = "LMBDrag",
|
||||
"RMBDrag" = "RMBDrag",
|
||||
"MMBDrag" = "MMBDrag",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: { IconLabel },
|
||||
props: {
|
||||
inputKeys: { type: Array, default: () => [] },
|
||||
inputMouse: { type: String },
|
||||
},
|
||||
computed: {
|
||||
hasSlotContent(): boolean {
|
||||
return Boolean(this.$slots.default);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
keyCapWidth(keyText: string) {
|
||||
return `width-${keyText.length * 8 + 8}`;
|
||||
},
|
||||
mouseInputInteractionToIcon(mouseInputInteraction: MouseInputInteraction) {
|
||||
switch (mouseInputInteraction) {
|
||||
case MouseInputInteraction.LMB:
|
||||
return "MouseHintLMB";
|
||||
case MouseInputInteraction.RMB:
|
||||
return "MouseHintRMB";
|
||||
case MouseInputInteraction.MMB:
|
||||
return "MouseHintMMB";
|
||||
case MouseInputInteraction.ScrollUp:
|
||||
return "MouseHintScrollUp";
|
||||
case MouseInputInteraction.ScrollDown:
|
||||
return "MouseHintScrollDown";
|
||||
case MouseInputInteraction.Drag:
|
||||
return "MouseHintDrag";
|
||||
case MouseInputInteraction.LMBDrag:
|
||||
return "MouseHintLMBDrag";
|
||||
case MouseInputInteraction.RMBDrag:
|
||||
return "MouseHintRMBDrag";
|
||||
case MouseInputInteraction.MMBDrag:
|
||||
return "MouseHintMMBDrag";
|
||||
default:
|
||||
case MouseInputInteraction.None:
|
||||
return "MouseHintNone";
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
MouseInputInteraction,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
146
frontend/src/components/widgets/options/ToolOptions.vue
Normal file
146
frontend/src/components/widgets/options/ToolOptions.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="tool-options">
|
||||
<template v-for="(option, index) in toolOptions[activeTool] || []" :key="index">
|
||||
<!-- TODO: Use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
|
||||
<IconButton v-if="option.kind === 'IconButton'" :action="() => handleIconButtonAction(option)" :title="option.tooltip" v-bind="option.props" />
|
||||
<PopoverButton v-if="option.kind === 'PopoverButton'" :title="option.tooltip" :action="option.callback" v-bind="option.props">
|
||||
<h3>{{ option.popover.title }}</h3>
|
||||
<p>{{ option.popover.text }}</p>
|
||||
</PopoverButton>
|
||||
<NumberInput v-if="option.kind === 'NumberInput'" v-model:value="option.props.value" @update:value="option.callback" :title="option.tooltip" v-bind="option.props" />
|
||||
<Separator v-if="option.kind === 'Separator'" v-bind="option.props" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.tool-options {
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import comingSoon from "@/utilities/coming-soon";
|
||||
|
||||
import { WidgetRow, SeparatorType, IconButtonWidget } from "@/components/widgets/widgets";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
|
||||
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
activeTool: { type: String },
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
async setToolOptions(newValue: number) {
|
||||
// TODO: Each value-input widget (i.e. not a button) should map to a field in an options struct,
|
||||
// and updating a widget should send the whole updated struct to the backend.
|
||||
// Later, it could send a single-field update to the backend.
|
||||
|
||||
const { set_tool_options } = await wasm;
|
||||
// This is a placeholder call, using the Shape tool as an example
|
||||
set_tool_options(this.$props.activeTool || "", { Shape: { shape_type: { Polygon: { vertices: newValue } } } });
|
||||
},
|
||||
async sendToolMessage(message: string | object) {
|
||||
const { send_tool_message } = await wasm;
|
||||
send_tool_message(this.$props.activeTool || "", message);
|
||||
},
|
||||
handleIconButtonAction(option: IconButtonWidget) {
|
||||
if (option.message) {
|
||||
this.sendToolMessage(option.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.callback) {
|
||||
option.callback();
|
||||
return;
|
||||
}
|
||||
|
||||
comingSoon();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const toolOptions: Record<string, WidgetRow> = {
|
||||
Select: [
|
||||
{ kind: "IconButton", message: { Align: ["X", "Min"] }, tooltip: "Align Left", props: { icon: "AlignLeft", size: 24 } },
|
||||
{ kind: "IconButton", message: { Align: ["X", "Center"] }, tooltip: "Align Horizontal Center", props: { icon: "AlignHorizontalCenter", size: 24 } },
|
||||
{ kind: "IconButton", message: { Align: ["X", "Max"] }, tooltip: "Align Right", props: { icon: "AlignRight", size: 24 } },
|
||||
|
||||
{ kind: "Separator", props: { type: SeparatorType.Unrelated } },
|
||||
|
||||
{ kind: "IconButton", message: { Align: ["Y", "Min"] }, tooltip: "Align Top", props: { icon: "AlignTop", size: 24 } },
|
||||
{ kind: "IconButton", message: { Align: ["Y", "Center"] }, tooltip: "Align Vertical Center", props: { icon: "AlignVerticalCenter", size: 24 } },
|
||||
{ kind: "IconButton", message: { Align: ["Y", "Max"] }, tooltip: "Align Bottom", props: { icon: "AlignBottom", size: 24 } },
|
||||
|
||||
{ kind: "Separator", props: { type: SeparatorType.Related } },
|
||||
|
||||
{
|
||||
kind: "PopoverButton",
|
||||
popover: {
|
||||
title: "Align",
|
||||
text: "More alignment-related buttons will be here",
|
||||
},
|
||||
props: {},
|
||||
},
|
||||
|
||||
{ kind: "Separator", props: { type: SeparatorType.Section } },
|
||||
|
||||
{ kind: "IconButton", message: "FlipHorizontal", tooltip: "Flip Horizontal", props: { icon: "FlipHorizontal", size: 24 } },
|
||||
{ kind: "IconButton", message: "FlipVertical", tooltip: "Flip Vertical", props: { icon: "FlipVertical", size: 24 } },
|
||||
|
||||
{ kind: "Separator", props: { type: SeparatorType.Related } },
|
||||
|
||||
{
|
||||
kind: "PopoverButton",
|
||||
popover: {
|
||||
title: "Flip",
|
||||
text: "More flip-related buttons will be here",
|
||||
},
|
||||
props: {},
|
||||
},
|
||||
|
||||
{ kind: "Separator", props: { type: SeparatorType.Section } },
|
||||
|
||||
{ kind: "IconButton", tooltip: "Boolean Union", callback: () => comingSoon(197), props: { icon: "BooleanUnion", size: 24 } },
|
||||
{ kind: "IconButton", tooltip: "Boolean Subtract Front", callback: () => comingSoon(197), props: { icon: "BooleanSubtractFront", size: 24 } },
|
||||
{ kind: "IconButton", tooltip: "Boolean Subtract Back", callback: () => comingSoon(197), props: { icon: "BooleanSubtractBack", size: 24 } },
|
||||
{ kind: "IconButton", tooltip: "Boolean Intersect", callback: () => comingSoon(197), props: { icon: "BooleanIntersect", size: 24 } },
|
||||
{ kind: "IconButton", tooltip: "Boolean Difference", callback: () => comingSoon(197), props: { icon: "BooleanDifference", size: 24 } },
|
||||
|
||||
{ kind: "Separator", props: { type: SeparatorType.Related } },
|
||||
|
||||
{
|
||||
kind: "PopoverButton",
|
||||
popover: {
|
||||
title: "Boolean",
|
||||
text: "More boolean-related buttons will be here",
|
||||
},
|
||||
props: {},
|
||||
},
|
||||
],
|
||||
Shape: [{ kind: "NumberInput", callback: this.setToolOptions, props: { value: 6, min: 3, isInteger: true, label: "Sides" } }],
|
||||
};
|
||||
|
||||
return {
|
||||
toolOptions,
|
||||
SeparatorType,
|
||||
comingSoon,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
Separator,
|
||||
IconButton,
|
||||
PopoverButton,
|
||||
NumberInput,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
113
frontend/src/components/widgets/rulers/CanvasRuler.vue
Normal file
113
frontend/src/components/widgets/rulers/CanvasRuler.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="canvas-ruler" :class="direction.toLowerCase()" ref="rulerRef">
|
||||
<svg :style="svgBounds">
|
||||
<path :d="svgPath" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.canvas-ruler {
|
||||
flex: 1 1 100%;
|
||||
background: var(--color-5-dullgray);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&.vertical {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
|
||||
path {
|
||||
stroke-width: 1px;
|
||||
stroke: var(--color-7-middlegray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
const RULER_THICKNESS = 16;
|
||||
|
||||
export enum RulerDirection {
|
||||
"Horizontal" = "Horizontal",
|
||||
"Vertical" = "Vertical",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
direction: { type: String as PropType<RulerDirection>, default: RulerDirection.Vertical },
|
||||
origin: { type: Number, required: true },
|
||||
majorMarkSpacing: { type: Number, required: true },
|
||||
mediumDivisions: { type: Number, default: 5 },
|
||||
minorDivisions: { type: Number, default: 2 },
|
||||
},
|
||||
computed: {
|
||||
svgPath(): string {
|
||||
const isVertical = this.direction === RulerDirection.Vertical;
|
||||
const lineDirection = isVertical ? "H" : "V";
|
||||
|
||||
let offsetStart = this.origin % this.majorMarkSpacing;
|
||||
if (offsetStart < this.majorMarkSpacing) offsetStart -= this.majorMarkSpacing;
|
||||
|
||||
const divisions = this.majorMarkSpacing / this.mediumDivisions / this.minorDivisions;
|
||||
const majorMarksFrequency = this.mediumDivisions * this.minorDivisions;
|
||||
|
||||
let dPathAttribute = "";
|
||||
let i = 0;
|
||||
for (let location = offsetStart; location < this.rulerLength; location += divisions) {
|
||||
let length = RULER_THICKNESS / 4;
|
||||
if (i % majorMarksFrequency === 0) length = RULER_THICKNESS;
|
||||
else if (i % this.minorDivisions === 0) length = RULER_THICKNESS / 2;
|
||||
i += 1;
|
||||
|
||||
const destination = Math.round(location) + 0.5;
|
||||
const startPoint = isVertical ? `${RULER_THICKNESS - length},${destination}` : `${destination},${RULER_THICKNESS - length}`;
|
||||
dPathAttribute += `M${startPoint}${lineDirection}${RULER_THICKNESS} `;
|
||||
}
|
||||
|
||||
return dPathAttribute;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleResize() {
|
||||
if (!this.$refs.rulerRef) return;
|
||||
|
||||
const rulerElement = this.$refs.rulerRef as HTMLElement;
|
||||
const isVertical = this.direction === RulerDirection.Vertical;
|
||||
|
||||
const newLength = isVertical ? rulerElement.clientHeight : rulerElement.clientWidth;
|
||||
const roundedUp = (Math.floor(newLength / this.majorMarkSpacing) + 1) * this.majorMarkSpacing;
|
||||
|
||||
if (roundedUp !== this.rulerLength) {
|
||||
this.rulerLength = roundedUp;
|
||||
const thickness = `${RULER_THICKNESS}px`;
|
||||
const length = `${roundedUp}px`;
|
||||
this.svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness };
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
this.handleResize();
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rulerLength: 0,
|
||||
svgBounds: { width: "0px", height: "0px" },
|
||||
RulerDirection,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="persistent-scrollbar" :class="direction.toLowerCase()">
|
||||
<button class="arrow decrease"></button>
|
||||
<div class="scroll-track">
|
||||
<div class="scroll-click-area decrease" :style="[trackStart, preThumb, sides]"></div>
|
||||
<div class="scroll-thumb" :style="[thumbStart, thumbEnd, sides]"></div>
|
||||
<div class="scroll-click-area increase" :style="[postThumb, trackEnd, sides]"></div>
|
||||
</div>
|
||||
<button class="arrow increase"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.persistent-scrollbar {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
|
||||
.arrow {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-style: solid;
|
||||
width: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scroll-track {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
|
||||
.scroll-thumb {
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
background: var(--color-5-dullgray);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-click-area {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
flex-direction: column;
|
||||
|
||||
.arrow.decrease {
|
||||
margin: 4px 3px;
|
||||
border-width: 0 5px 8px 5px;
|
||||
border-color: transparent transparent var(--color-5-dullgray) transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: transparent transparent var(--color-6-lowergray) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow.increase {
|
||||
margin: 4px 3px;
|
||||
border-width: 8px 5px 0 5px;
|
||||
border-color: var(--color-5-dullgray) transparent transparent transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-6-lowergray) transparent transparent transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
flex-direction: row;
|
||||
|
||||
.arrow.decrease {
|
||||
margin: 3px 4px;
|
||||
border-width: 5px 8px 5px 0;
|
||||
border-color: transparent var(--color-5-dullgray) transparent transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: transparent var(--color-6-lowergray) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow.increase {
|
||||
margin: 3px 4px;
|
||||
border-width: 5px 0 5px 8px;
|
||||
border-color: transparent transparent transparent var(--color-5-dullgray);
|
||||
|
||||
&:hover {
|
||||
border-color: transparent transparent transparent var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
export enum ScrollbarDirection {
|
||||
"Horizontal" = "Horizontal",
|
||||
"Vertical" = "Vertical",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
direction: { type: String as PropType<ScrollbarDirection>, default: ScrollbarDirection.Vertical },
|
||||
},
|
||||
computed: {
|
||||
trackStart(): { left: string } | { top: string } {
|
||||
return this.direction === ScrollbarDirection.Vertical ? { top: "0%" } : { left: "0%" };
|
||||
},
|
||||
preThumb(): { right: string } | { bottom: string } {
|
||||
const start = 25;
|
||||
|
||||
return this.direction === ScrollbarDirection.Vertical ? { bottom: `${100 - start}%` } : { right: `${100 - start}%` };
|
||||
},
|
||||
thumbStart(): { left: string } | { top: string } {
|
||||
const start = 25;
|
||||
|
||||
return this.direction === ScrollbarDirection.Vertical ? { top: `${start}%` } : { left: `${start}%` };
|
||||
},
|
||||
thumbEnd(): { right: string } | { bottom: string } {
|
||||
const end = 25;
|
||||
|
||||
return this.direction === ScrollbarDirection.Vertical ? { bottom: `${end}%` } : { right: `${end}%` };
|
||||
},
|
||||
postThumb(): { left: string } | { top: string } {
|
||||
const end = 25;
|
||||
|
||||
return this.direction === ScrollbarDirection.Vertical ? { top: `${100 - end}%` } : { left: `${100 - end}%` };
|
||||
},
|
||||
trackEnd(): { right: string } | { bottom: string } {
|
||||
return this.direction === ScrollbarDirection.Vertical ? { bottom: "0%" } : { right: "0%" };
|
||||
},
|
||||
sides(): { left: string; right: string } | { top: string; bottom: string } {
|
||||
return this.direction === ScrollbarDirection.Vertical ? { left: "0%", right: "0%" } : { top: "0%", bottom: "0%" };
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ScrollbarDirection,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
88
frontend/src/components/widgets/separators/Separator.vue
Normal file
88
frontend/src/components/widgets/separators/Separator.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="separator" :class="[direction.toLowerCase(), type.toLowerCase()]">
|
||||
<div v-if="[SeparatorType.Section, SeparatorType.List].includes(type)"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.separator {
|
||||
&.vertical {
|
||||
&.related {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&.unrelated {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&.section,
|
||||
&.list {
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
height: 1px;
|
||||
width: calc(100% - 8px);
|
||||
margin: 0 4px;
|
||||
background: var(--color-7-middlegray);
|
||||
}
|
||||
}
|
||||
|
||||
&.section {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
&.list {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
&.related {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&.unrelated {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&.section,
|
||||
&.list {
|
||||
height: 100%;
|
||||
|
||||
div {
|
||||
height: calc(100% - 8px);
|
||||
width: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--color-7-middlegray);
|
||||
}
|
||||
}
|
||||
|
||||
&.section {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
&.list {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
direction: { type: String, default: SeparatorDirection.Horizontal },
|
||||
type: { type: String, default: SeparatorType.Unrelated },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
SeparatorDirection,
|
||||
SeparatorType,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
97
frontend/src/components/widgets/widgets.ts
Normal file
97
frontend/src/components/widgets/widgets.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export type Widgets = TextButtonWidget | IconButtonWidget | SeparatorWidget | PopoverButtonWidget | NumberInputWidget;
|
||||
export type WidgetRow = Array<Widgets>;
|
||||
export type WidgetLayout = Array<WidgetRow>;
|
||||
|
||||
// Text Button
|
||||
export interface TextButtonWidget {
|
||||
kind: "TextButton";
|
||||
tooltip?: string;
|
||||
message?: string | object;
|
||||
callback?: Function;
|
||||
props: TextButtonProps;
|
||||
}
|
||||
|
||||
export interface TextButtonProps {
|
||||
// `action` is used via `IconButtonWidget.callback`
|
||||
label: string;
|
||||
emphasized?: boolean;
|
||||
disabled?: boolean;
|
||||
minWidth?: number;
|
||||
gapAfter?: boolean;
|
||||
}
|
||||
|
||||
// Icon Button
|
||||
export interface IconButtonWidget {
|
||||
kind: "IconButton";
|
||||
tooltip?: string;
|
||||
message?: string | object;
|
||||
callback?: Function;
|
||||
props: IconButtonProps;
|
||||
}
|
||||
|
||||
export interface IconButtonProps {
|
||||
// `action` is used via `IconButtonWidget.callback`
|
||||
icon: string;
|
||||
size: number;
|
||||
gapAfter?: boolean;
|
||||
}
|
||||
|
||||
// Popover Button
|
||||
export interface PopoverButtonWidget {
|
||||
kind: "PopoverButton";
|
||||
tooltip?: string;
|
||||
callback?: Function;
|
||||
// popover: WidgetLayout;
|
||||
popover: { title: string; text: string }; // TODO: Replace this with a `WidgetLayout` like above for arbitrary layouts
|
||||
props: PopoverButtonProps;
|
||||
}
|
||||
|
||||
export interface PopoverButtonProps {
|
||||
// `action` is used via `PopoverButtonWidget.callback`
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// Number Input
|
||||
export interface NumberInputWidget {
|
||||
kind: "NumberInput";
|
||||
tooltip?: string;
|
||||
callback?: Function;
|
||||
props: NumberInputProps;
|
||||
}
|
||||
|
||||
export interface NumberInputProps {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
stepIsMultiplier?: boolean;
|
||||
isInteger?: boolean;
|
||||
unit?: string;
|
||||
unitIsHiddenWhenEditing?: boolean;
|
||||
displayDecimalPlaces?: number;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Separator
|
||||
export interface SeparatorWidget {
|
||||
kind: "Separator";
|
||||
props: SeparatorProps;
|
||||
}
|
||||
|
||||
export interface SeparatorProps {
|
||||
direction?: SeparatorDirection;
|
||||
type?: SeparatorType;
|
||||
}
|
||||
|
||||
export enum SeparatorDirection {
|
||||
"Horizontal" = "Horizontal",
|
||||
"Vertical" = "Vertical",
|
||||
}
|
||||
|
||||
export enum SeparatorType {
|
||||
"Related" = "Related",
|
||||
"Unrelated" = "Unrelated",
|
||||
"Section" = "Section",
|
||||
"List" = "List",
|
||||
}
|
||||
68
frontend/src/components/window/MainWindow.vue
Normal file
68
frontend/src/components/window/MainWindow.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<LayoutCol class="main-window">
|
||||
<LayoutRow :class="'title-bar-row'">
|
||||
<TitleBar :platform="platform" :maximized="maximized" />
|
||||
</LayoutRow>
|
||||
<LayoutRow :class="'workspace-row'">
|
||||
<Workspace />
|
||||
</LayoutRow>
|
||||
<LayoutRow :class="'status-bar-row'">
|
||||
<StatusBar />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.main-window {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.title-bar-row {
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-row {
|
||||
position: relative;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.status-bar-row {
|
||||
flex: 0 0 auto;
|
||||
// Prevents the creation of a scrollbar due to the child's negative margin
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import TitleBar from "@/components/window/title-bar/TitleBar.vue";
|
||||
import StatusBar from "@/components/window/status-bar/StatusBar.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import Workspace from "@/components/workspace/Workspace.vue";
|
||||
|
||||
export enum ApplicationPlatform {
|
||||
"Windows" = "Windows",
|
||||
"Mac" = "Mac",
|
||||
"Linux" = "Linux",
|
||||
"Web" = "Web",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
LayoutRow,
|
||||
LayoutCol,
|
||||
TitleBar,
|
||||
Workspace,
|
||||
StatusBar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
platform: ApplicationPlatform.Web,
|
||||
maximized: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
75
frontend/src/components/window/status-bar/StatusBar.vue
Normal file
75
frontend/src/components/window/status-bar/StatusBar.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="status-bar">
|
||||
<UserInputLabel :inputMouse="'LMBDrag'">Drag Selected</UserInputLabel>
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
<UserInputLabel :inputKeys="[['G']]">Grab Selected</UserInputLabel>
|
||||
<UserInputLabel :inputKeys="[['R']]">Rotate Selected</UserInputLabel>
|
||||
<UserInputLabel :inputKeys="[['S']]">Scale Selected</UserInputLabel>
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
<UserInputLabel :inputMouse="'LMB'">Select Object</UserInputLabel>
|
||||
<span class="plus">+</span>
|
||||
<UserInputLabel :inputKeys="[['Ctrl']]">Innermost</UserInputLabel>
|
||||
<span class="plus">+</span>
|
||||
<UserInputLabel :inputKeys="[['⇧']]">Grow/Shrink Selection</UserInputLabel>
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
<UserInputLabel :inputMouse="'LMBDrag'">Select Area</UserInputLabel>
|
||||
<span class="plus">+</span>
|
||||
<UserInputLabel :inputKeys="[['⇧']]">Grow/Shrink Selection</UserInputLabel>
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
<UserInputLabel :inputKeys="[['↑'], ['→'], ['↓'], ['←']]">Nudge Selected</UserInputLabel>
|
||||
<span class="plus">+</span>
|
||||
<UserInputLabel :inputKeys="[['⇧']]">Big Increment Nudge</UserInputLabel>
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
<UserInputLabel :inputKeys="[['Alt']]" :inputMouse="'LMBDrag'">Move Duplicate</UserInputLabel>
|
||||
<UserInputLabel :inputKeys="[['Ctrl', 'D']]">Duplicate</UserInputLabel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.status-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -4px;
|
||||
// TODO: Use CSS grid to solve issue that makes overflowed items have inconsistent left padding on second row when overflowed
|
||||
|
||||
> * {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.separator.section {
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-input-label + .user-input-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import { SeparatorType } from "@/components/widgets/widgets";
|
||||
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
|
||||
import Separator from "@/components/widgets/separators/Separator.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
UserInputLabel,
|
||||
Separator,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
SeparatorType,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
62
frontend/src/components/window/title-bar/TitleBar.vue
Normal file
62
frontend/src/components/window/title-bar/TitleBar.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="header-third">
|
||||
<WindowButtonsMac :maximized="maximized" v-if="platform === ApplicationPlatform.Mac" />
|
||||
<MenuBarInput v-if="platform !== ApplicationPlatform.Mac" />
|
||||
</div>
|
||||
<div class="header-third">
|
||||
<WindowTitle :title="`${documents.title}${documents.unsaved ? '*' : ''} - Graphite`" />
|
||||
</div>
|
||||
<div class="header-third">
|
||||
<WindowButtonsWindows :maximized="maximized" v-if="platform === ApplicationPlatform.Windows || platform === ApplicationPlatform.Linux" />
|
||||
<WindowButtonsWeb :maximized="maximized" v-if="platform === ApplicationPlatform.Web" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.header-third {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
|
||||
&:nth-child(1) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import WindowTitle from "@/components/window/title-bar/WindowTitle.vue";
|
||||
import WindowButtonsWindows from "@/components/window/title-bar/WindowButtonsWindows.vue";
|
||||
import WindowButtonsMac from "@/components/window/title-bar/WindowButtonsMac.vue";
|
||||
import WindowButtonsWeb from "@/components/window/title-bar/WindowButtonsWeb.vue";
|
||||
import MenuBarInput from "@/components/widgets/inputs/MenuBarInput.vue";
|
||||
import { ApplicationPlatform } from "@/components/window/MainWindow.vue";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["documents"],
|
||||
props: {
|
||||
platform: { type: String, required: true },
|
||||
maximized: { type: Boolean, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ApplicationPlatform,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
MenuBarInput,
|
||||
WindowTitle,
|
||||
WindowButtonsWindows,
|
||||
WindowButtonsMac,
|
||||
WindowButtonsWeb,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="mac window-buttons">
|
||||
<div class="close" title="Close"></div>
|
||||
<div class="minimize" title="Minimize"></div>
|
||||
<div class="zoom" title="Zoom"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.mac.window-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.close {
|
||||
background: #ff5a52;
|
||||
}
|
||||
|
||||
&.minimize {
|
||||
background: #e6c029;
|
||||
}
|
||||
|
||||
&.zoom {
|
||||
background: #54c22b;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
maximized: { type: Boolean, default: false },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="window-buttons-web" @click="handleClick" :title="fullscreen.windowFullscreen ? 'Exit Fullscreen (F11)' : 'Enter Fullscreen (F11)'">
|
||||
<TextLabel v-if="requestFullscreenHotkeys" :italic="true">Click to access all hotkeys</TextLabel>
|
||||
<IconLabel :icon="fullscreen.windowFullscreen ? 'FullscreenExit' : 'FullscreenEnter'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.window-buttons-web {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
.text-label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import fullscreen, { keyboardLockApiSupported, enterFullscreen, exitFullscreen } from "@/utilities/fullscreen";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||
|
||||
const canUseKeyboardLock = keyboardLockApiSupported();
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["fullscreen"],
|
||||
methods: {
|
||||
async handleClick() {
|
||||
if (fullscreen.windowFullscreen) exitFullscreen();
|
||||
else enterFullscreen();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
requestFullscreenHotkeys() {
|
||||
return canUseKeyboardLock && !fullscreen.keyboardLocked;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
IconLabel,
|
||||
TextLabel,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="windows window-button minimize" title="Minimize">
|
||||
<IconLabel :icon="'WindowButtonWinMinimize'" />
|
||||
</div>
|
||||
<div class="windows window-button maximize" title="Maximize" v-if="!maximized">
|
||||
<IconLabel :icon="'WindowButtonWinMaximize'" />
|
||||
</div>
|
||||
<div class="windows window-button restore-down" title="Restore Down" v-if="maximized">
|
||||
<IconLabel :icon="'WindowButtonWinRestoreDown'" />
|
||||
</div>
|
||||
<div class="windows window-button close" title="Close">
|
||||
<IconLabel :icon="'WindowButtonWinClose'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.windows.window-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 17px;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.close:hover {
|
||||
background: #e81123;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { IconLabel },
|
||||
props: {
|
||||
maximized: { type: Boolean, default: false },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
24
frontend/src/components/window/title-bar/WindowTitle.vue
Normal file
24
frontend/src/components/window/title-bar/WindowTitle.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="window-title">
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
title: { type: String, required: true },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
182
frontend/src/components/workspace/Panel.vue
Normal file
182
frontend/src/components/workspace/Panel.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="tab-bar" :class="{ 'min-widths': tabMinWidths }">
|
||||
<div class="tab-group">
|
||||
<div
|
||||
class="tab"
|
||||
:class="{ active: tabIndex === tabActiveIndex }"
|
||||
v-for="(tabLabel, tabIndex) in tabLabels"
|
||||
:key="tabIndex"
|
||||
@click.middle="closeDocumentWithConfirmation(tabIndex)"
|
||||
@click="selectDocument(tabIndex)"
|
||||
>
|
||||
<span>{{ tabLabel }}</span>
|
||||
<IconButton :action="() => closeDocumentWithConfirmation(tabIndex)" :icon="'CloseX'" :size="16" v-if="tabCloseButtons" />
|
||||
</div>
|
||||
</div>
|
||||
<PopoverButton :icon="PopoverButtonIcon.VerticalEllipsis">
|
||||
<h3>Panel Options</h3>
|
||||
<p>More panel-related options will be here</p>
|
||||
</PopoverButton>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<component :is="panelType" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.panel {
|
||||
background: var(--color-1-nearblack);
|
||||
border-radius: 8px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-bar {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&.min-widths .tab-group .tab {
|
||||
min-width: 124px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
flex: 1 1 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
|
||||
.tab {
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background: var(--color-3-darkgray);
|
||||
border-radius: 8px 8px 0 0;
|
||||
position: relative;
|
||||
|
||||
&:not(:first-child)::before,
|
||||
&::after {
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child)::before {
|
||||
left: -16px;
|
||||
border-bottom-right-radius: 8px;
|
||||
box-shadow: 8px 0 0 0 var(--color-3-darkgray);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -16px;
|
||||
border-bottom-left-radius: 8px;
|
||||
box-shadow: -8px 0 0 0 var(--color-3-darkgray);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1 1 100%;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
// Height and line-height required because https://stackoverflow.com/a/21611191/775283
|
||||
height: 100%;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
& + .tab {
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
&:not(.active) + .tab:not(.active)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 1px;
|
||||
|
||||
&:not(.active)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popover-button {
|
||||
margin: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
background: var(--color-3-darkgray);
|
||||
flex: 1 1 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
import { selectDocument, closeDocumentWithConfirmation } from "@/utilities/documents";
|
||||
|
||||
import Document from "@/components/panels/Document.vue";
|
||||
import Properties from "@/components/panels/Properties.vue";
|
||||
import LayerTree from "@/components/panels/LayerTree.vue";
|
||||
import Minimap from "@/components/panels/Minimap.vue";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||
import PopoverButton, { PopoverButtonIcon } from "@/components/widgets/buttons/PopoverButton.vue";
|
||||
import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Document,
|
||||
Properties,
|
||||
LayerTree,
|
||||
Minimap,
|
||||
IconButton,
|
||||
PopoverButton,
|
||||
},
|
||||
props: {
|
||||
tabMinWidths: { type: Boolean, default: false },
|
||||
tabCloseButtons: { type: Boolean, default: false },
|
||||
tabLabels: { type: Array as PropType<string[]>, required: true },
|
||||
tabActiveIndex: { type: Number, required: true },
|
||||
panelType: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectDocument,
|
||||
closeDocumentWithConfirmation,
|
||||
PopoverButtonIcon,
|
||||
MenuDirection,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
68
frontend/src/components/workspace/Workspace.vue
Normal file
68
frontend/src/components/workspace/Workspace.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<LayoutRow class="workspace-grid-subdivision">
|
||||
<LayoutCol class="workspace-grid-subdivision" style="flex-grow: 1597">
|
||||
<Panel :panelType="'Document'" :tabCloseButtons="true" :tabMinWidths="true" :tabLabels="documents.documents" :tabActiveIndex="documents.activeDocumentIndex" />
|
||||
</LayoutCol>
|
||||
<LayoutCol class="workspace-grid-resize-gutter"></LayoutCol>
|
||||
<LayoutCol class="workspace-grid-subdivision" style="flex-grow: 319">
|
||||
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 402">
|
||||
<Panel :panelType="'Properties'" :tabLabels="['Properties', 'Spreadsheet', 'Palettes']" :tabActiveIndex="0" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="workspace-grid-resize-gutter"></LayoutRow>
|
||||
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 590">
|
||||
<Panel :panelType="'LayerTree'" :tabLabels="['Layer Tree']" :tabActiveIndex="0" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="workspace-grid-resize-gutter"></LayoutRow>
|
||||
<LayoutRow class="workspace-grid-subdivision folded">
|
||||
<Panel :panelType="'Minimap'" :tabLabels="['Minimap', 'Asset Manager']" :tabActiveIndex="0" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
<DialogModal v-if="dialog.visible" />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.workspace-grid-subdivision {
|
||||
min-height: 28px;
|
||||
flex: 1 1 0;
|
||||
|
||||
&.folded {
|
||||
flex-grow: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-grid-resize-gutter {
|
||||
flex: 0 0 4px;
|
||||
|
||||
&.layout-row {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
&.layout-col {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
import Panel from "@/components/workspace/Panel.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import DialogModal from "@/components/widgets/floating-menus/DialogModal.vue";
|
||||
|
||||
export default defineComponent({
|
||||
inject: ["documents", "dialog"],
|
||||
components: {
|
||||
LayoutRow,
|
||||
LayoutCol,
|
||||
Panel,
|
||||
DialogModal,
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
14
frontend/src/main.ts
Normal file
14
frontend/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApp } from "vue";
|
||||
import { fullscreenModeChanged } from "@/utilities/fullscreen";
|
||||
import { handleKeyUp, handleKeyDown, handleMouseDown } from "@/utilities/input";
|
||||
import App from "@/App.vue";
|
||||
|
||||
// Bind global browser events
|
||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
document.addEventListener("fullscreenchange", () => fullscreenModeChanged());
|
||||
window.addEventListener("keyup", (e: KeyboardEvent) => handleKeyUp(e));
|
||||
window.addEventListener("keydown", (e: KeyboardEvent) => handleKeyDown(e));
|
||||
window.addEventListener("mousedown", (e: MouseEvent) => handleMouseDown(e));
|
||||
|
||||
// Initialize the Vue application
|
||||
createApp(App).mount("#app");
|
||||
9
frontend/src/types.d.ts
vendored
Normal file
9
frontend/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module "*.vue" {
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
79
frontend/src/utilities/color.ts
Normal file
79
frontend/src/utilities/color.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export interface HSV {
|
||||
h: number;
|
||||
s: number;
|
||||
v: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export function hsvToRgb(hsv: HSV): RGB {
|
||||
let { h } = hsv;
|
||||
const { s, v } = hsv;
|
||||
h *= 6;
|
||||
const i = Math.floor(h);
|
||||
const f = h - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f * s);
|
||||
const t = v * (1 - (1 - f) * s);
|
||||
const mod = i % 6;
|
||||
const r = Math.round([v, q, p, p, t, v][mod]);
|
||||
const g = Math.round([t, v, v, q, p, p][mod]);
|
||||
const b = Math.round([p, p, t, v, v, q][mod]);
|
||||
return { r, g, b, a: hsv.a };
|
||||
}
|
||||
|
||||
export function rgbToHsv(rgb: RGB) {
|
||||
const { r, g, b } = rgb;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
const v = max;
|
||||
let h = 0;
|
||||
if (max === min) {
|
||||
h = 0;
|
||||
} else {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return { h, s, v, a: rgb.a };
|
||||
}
|
||||
|
||||
export function rgbToDecimalRgb(rgb: RGB) {
|
||||
const r = rgb.r / 255;
|
||||
const g = rgb.g / 255;
|
||||
const b = rgb.b / 255;
|
||||
return { r, g, b, a: rgb.a };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function isRGB(data: any): data is RGB {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
return (
|
||||
typeof data.r === "number" &&
|
||||
!Number.isNaN(data.r) &&
|
||||
typeof data.g === "number" &&
|
||||
!Number.isNaN(data.g) &&
|
||||
typeof data.b === "number" &&
|
||||
!Number.isNaN(data.b) &&
|
||||
typeof data.a === "number" &&
|
||||
!Number.isNaN(data.a)
|
||||
);
|
||||
}
|
||||
22
frontend/src/utilities/coming-soon.ts
Normal file
22
frontend/src/utilities/coming-soon.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createDialog, dismissDialog } from "@/utilities/dialog";
|
||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||
|
||||
export default function comingSoon(issueNumber?: number) {
|
||||
const bugMessage = `— but you can help add it!\nSee issue #${issueNumber} on GitHub.`;
|
||||
const details = `This feature is not implemented yet${issueNumber ? bugMessage : ""}`;
|
||||
|
||||
const okButton: TextButtonWidget = {
|
||||
kind: "TextButton",
|
||||
callback: async () => dismissDialog(),
|
||||
props: { label: "OK", emphasized: true, minWidth: 96 },
|
||||
};
|
||||
const issueButton: TextButtonWidget = {
|
||||
kind: "TextButton",
|
||||
callback: async () => window.open(`https://github.com/GraphiteEditor/Graphite/issues/${issueNumber}`, "_blank"),
|
||||
props: { label: `Issue #${issueNumber}`, minWidth: 96 },
|
||||
};
|
||||
const buttons = [okButton];
|
||||
if (issueNumber) buttons.push(issueButton);
|
||||
|
||||
createDialog("Warning", "Coming soon", details, buttons);
|
||||
}
|
||||
37
frontend/src/utilities/dialog.ts
Normal file
37
frontend/src/utilities/dialog.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { TextButtonWidget } from "@/components/widgets/widgets";
|
||||
|
||||
const state = reactive({
|
||||
visible: false,
|
||||
icon: "",
|
||||
heading: "",
|
||||
details: "",
|
||||
buttons: [] as TextButtonWidget[],
|
||||
});
|
||||
|
||||
export function createDialog(icon: string, heading: string, details: string, buttons: TextButtonWidget[]) {
|
||||
state.visible = true;
|
||||
state.icon = icon;
|
||||
state.heading = heading;
|
||||
state.details = details;
|
||||
state.buttons = buttons;
|
||||
}
|
||||
|
||||
export function dismissDialog() {
|
||||
state.visible = false;
|
||||
}
|
||||
|
||||
export function submitDialog() {
|
||||
const firstEmphasizedButton = state.buttons.find((button) => button.props.emphasized && button.callback);
|
||||
if (firstEmphasizedButton) {
|
||||
// If statement satisfies TypeScript
|
||||
if (firstEmphasizedButton.callback) firstEmphasizedButton.callback();
|
||||
}
|
||||
}
|
||||
|
||||
export function dialogIsVisible(): boolean {
|
||||
return state.visible;
|
||||
}
|
||||
|
||||
export default readonly(state);
|
||||
97
frontend/src/utilities/documents.ts
Normal file
97
frontend/src/utilities/documents.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
import { createDialog, dismissDialog } from "@/utilities/dialog";
|
||||
import { ResponseType, registerResponseHandler, Response, SetActiveDocument, UpdateOpenDocumentsList, DisplayConfirmationToCloseDocument } from "@/utilities/response-handler";
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
const state = reactive({
|
||||
title: "",
|
||||
unsaved: false,
|
||||
documents: [] as Array<string>,
|
||||
activeDocumentIndex: 0,
|
||||
});
|
||||
|
||||
export async function selectDocument(tabIndex: number) {
|
||||
const { select_document } = await wasm;
|
||||
select_document(tabIndex);
|
||||
}
|
||||
|
||||
export async function closeDocumentWithConfirmation(tabIndex: number) {
|
||||
selectDocument(tabIndex);
|
||||
|
||||
const tabLabel = state.documents[tabIndex];
|
||||
|
||||
// TODO: Rename to "Save changes before closing?" when we can actually save documents somewhere, not just export SVGs
|
||||
createDialog("File", "Close without exporting SVG?", tabLabel, [
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
(await wasm).export_document();
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Export", emphasized: true, minWidth: 96 },
|
||||
},
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
(await wasm).close_document(tabIndex);
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Discard", minWidth: 96 },
|
||||
},
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Cancel", minWidth: 96 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function closeAllDocumentsWithConfirmation() {
|
||||
createDialog("Copy", "Close all documents?", "Unsaved work will be lost!", [
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
(await wasm).close_all_documents();
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Discard All", minWidth: 96 },
|
||||
},
|
||||
{
|
||||
kind: "TextButton",
|
||||
callback: async () => {
|
||||
dismissDialog();
|
||||
},
|
||||
props: { label: "Cancel", minWidth: 96 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export default readonly(state);
|
||||
|
||||
registerResponseHandler(ResponseType.UpdateOpenDocumentsList, (responseData: Response) => {
|
||||
const documentListData = responseData as UpdateOpenDocumentsList;
|
||||
if (documentListData) {
|
||||
state.documents = documentListData.open_documents;
|
||||
state.title = state.documents[state.activeDocumentIndex];
|
||||
}
|
||||
});
|
||||
registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response) => {
|
||||
const documentData = responseData as SetActiveDocument;
|
||||
if (documentData) {
|
||||
state.activeDocumentIndex = documentData.document_index;
|
||||
state.title = state.documents[state.activeDocumentIndex];
|
||||
}
|
||||
});
|
||||
registerResponseHandler(ResponseType.DisplayConfirmationToCloseDocument, (responseData: Response) => {
|
||||
const data = responseData as DisplayConfirmationToCloseDocument;
|
||||
closeDocumentWithConfirmation(data.document_index);
|
||||
});
|
||||
registerResponseHandler(ResponseType.DisplayConfirmationToCloseAllDocuments, (_responseData: Response) => {
|
||||
closeAllDocumentsWithConfirmation();
|
||||
});
|
||||
|
||||
(async () => (await wasm).get_open_documents_list())();
|
||||
37
frontend/src/utilities/fullscreen.ts
Normal file
37
frontend/src/utilities/fullscreen.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
const state = reactive({
|
||||
windowFullscreen: false,
|
||||
keyboardLocked: false,
|
||||
});
|
||||
|
||||
export function fullscreenModeChanged() {
|
||||
state.windowFullscreen = Boolean(document.fullscreenElement);
|
||||
if (!state.windowFullscreen) state.keyboardLocked = false;
|
||||
}
|
||||
|
||||
export function keyboardLockApiSupported(): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return "keyboard" in navigator && "lock" in (navigator as any).keyboard;
|
||||
}
|
||||
|
||||
export async function enterFullscreen() {
|
||||
await document.documentElement.requestFullscreen();
|
||||
|
||||
if (keyboardLockApiSupported()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (navigator as any).keyboard.lock(["ControlLeft", "ControlRight"]);
|
||||
state.keyboardLocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function exitFullscreen() {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
|
||||
export async function toggleFullscreen() {
|
||||
if (state.windowFullscreen) await exitFullscreen();
|
||||
else await enterFullscreen();
|
||||
}
|
||||
|
||||
export default readonly(state);
|
||||
76
frontend/src/utilities/input.ts
Normal file
76
frontend/src/utilities/input.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { toggleFullscreen } from "@/utilities/fullscreen";
|
||||
import { dialogIsVisible, dismissDialog, submitDialog } from "@/utilities/dialog";
|
||||
|
||||
const wasm = import("@/../wasm/pkg");
|
||||
|
||||
export function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent): boolean {
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable) return false;
|
||||
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (dialogIsVisible()) return false;
|
||||
|
||||
// Don't redirect a fullscreen request
|
||||
if (e.key.toLowerCase() === "f11" && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't redirect a reload request
|
||||
if (e.key.toLowerCase() === "f5") return false;
|
||||
|
||||
// Don't redirect debugging tools
|
||||
if (e.key.toLowerCase() === "f12") return false;
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") return false;
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "i") return false;
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "j") return false;
|
||||
|
||||
// Redirect to the backend
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function handleKeyDown(e: KeyboardEvent) {
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const { on_key_down } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_key_down(e.key, modifiers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogIsVisible()) {
|
||||
if (e.key === "Escape") dismissDialog();
|
||||
if (e.key === "Enter") submitDialog();
|
||||
|
||||
// Prevent the Enter key from acting like a click on the last clicked button, which might reopen the dialog
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleKeyUp(e: KeyboardEvent) {
|
||||
if (shouldRedirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
const { on_key_up } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_key_up(e.key, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleMouseDown(e: MouseEvent) {
|
||||
const target = e.target && (e.target as HTMLElement);
|
||||
const clickedInsideDialog = target && target.closest(".dialog-modal .floating-menu-content");
|
||||
|
||||
if (dialogIsVisible() && !clickedInsideDialog) {
|
||||
dismissDialog();
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
export function makeModifiersBitfield(control: boolean, shift: boolean, alt: boolean): number {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return Number(control) | (Number(shift) << 1) | (Number(alt) << 2);
|
||||
}
|
||||
3
frontend/src/utilities/math.ts
Normal file
3
frontend/src/utilities/math.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function clamp(value: number, min = 0, max = 1) {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
4
frontend/src/utilities/response-handler-binding.ts
Normal file
4
frontend/src/utilities/response-handler-binding.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file is instantiated by wasm-bindgen in `/frontend/wasm/src/lib.rs` and re-exports the `handleResponse` function to
|
||||
// provide access to the global copy of `response-handler.ts` with its shared state, not an isolated duplicate with empty state
|
||||
|
||||
export { handleResponse } from "@/utilities/response-handler";
|
||||
333
frontend/src/utilities/response-handler.ts
Normal file
333
frontend/src/utilities/response-handler.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
type ResponseCallback = (responseData: Response) => void;
|
||||
type ResponseMap = {
|
||||
[response: string]: ResponseCallback | undefined;
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
responseMap: {} as ResponseMap,
|
||||
});
|
||||
|
||||
export enum ResponseType {
|
||||
UpdateCanvas = "UpdateCanvas",
|
||||
ExportDocument = "ExportDocument",
|
||||
ExpandFolder = "ExpandFolder",
|
||||
CollapseFolder = "CollapseFolder",
|
||||
SetActiveTool = "SetActiveTool",
|
||||
SetActiveDocument = "SetActiveDocument",
|
||||
UpdateOpenDocumentsList = "UpdateOpenDocumentsList",
|
||||
UpdateWorkingColors = "UpdateWorkingColors",
|
||||
UpdateLayer = "UpdateLayer",
|
||||
SetCanvasZoom = "SetCanvasZoom",
|
||||
SetCanvasRotation = "SetCanvasRotation",
|
||||
DisplayConfirmationToCloseDocument = "DisplayConfirmationToCloseDocument",
|
||||
DisplayConfirmationToCloseAllDocuments = "DisplayConfirmationToCloseAllDocuments",
|
||||
}
|
||||
|
||||
export function registerResponseHandler(responseType: ResponseType, callback: ResponseCallback) {
|
||||
state.responseMap[responseType] = callback;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function handleResponse(responseType: string, responseData: any) {
|
||||
const callback = state.responseMap[responseType];
|
||||
const data = parseResponse(responseType, responseData);
|
||||
|
||||
if (callback && data) {
|
||||
callback(data);
|
||||
} else if (data) {
|
||||
console.error(`Received a Response of type "${responseType}" but no handler was registered for it from the client.`);
|
||||
} else {
|
||||
console.error(`Received a Response of type "${responseType}" but but was not able to parse the data.`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function parseResponse(responseType: string, data: any): Response {
|
||||
switch (responseType) {
|
||||
case "DocumentChanged":
|
||||
return newDocumentChanged(data.DocumentChanged);
|
||||
case "CollapseFolder":
|
||||
return newCollapseFolder(data.CollapseFolder);
|
||||
case "ExpandFolder":
|
||||
return newExpandFolder(data.ExpandFolder);
|
||||
case "SetActiveTool":
|
||||
return newSetActiveTool(data.SetActiveTool);
|
||||
case "SetActiveDocument":
|
||||
return newSetActiveDocument(data.SetActiveDocument);
|
||||
case "UpdateOpenDocumentsList":
|
||||
return newUpdateOpenDocumentsList(data.UpdateOpenDocumentsList);
|
||||
case "UpdateCanvas":
|
||||
return newUpdateCanvas(data.UpdateCanvas);
|
||||
case "UpdateLayer":
|
||||
return newUpdateLayer(data.UpdateLayer);
|
||||
case "SetCanvasZoom":
|
||||
return newSetCanvasZoom(data.SetCanvasZoom);
|
||||
case "SetCanvasRotation":
|
||||
return newSetCanvasRotation(data.SetCanvasRotation);
|
||||
case "ExportDocument":
|
||||
return newExportDocument(data.ExportDocument);
|
||||
case "UpdateWorkingColors":
|
||||
return newUpdateWorkingColors(data.UpdateWorkingColors);
|
||||
case "DisplayConfirmationToCloseDocument":
|
||||
return newDisplayConfirmationToCloseDocument(data.DisplayConfirmationToCloseDocument);
|
||||
case "DisplayConfirmationToCloseAllDocuments":
|
||||
return newDisplayConfirmationToCloseAllDocuments(data.DisplayConfirmationToCloseAllDocuments);
|
||||
default:
|
||||
throw new Error(`Unrecognized origin/responseType pair: ${origin}, '${responseType}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export type Response = SetActiveTool | UpdateCanvas | DocumentChanged | CollapseFolder | ExpandFolder | UpdateWorkingColors | SetCanvasZoom | SetCanvasRotation;
|
||||
|
||||
export interface UpdateOpenDocumentsList {
|
||||
open_documents: Array<string>;
|
||||
}
|
||||
function newUpdateOpenDocumentsList(input: any): UpdateOpenDocumentsList {
|
||||
return { open_documents: input.open_documents };
|
||||
}
|
||||
|
||||
export interface Color {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
alpha: number;
|
||||
}
|
||||
function newColor(input: any): Color {
|
||||
// TODO: Possibly change this in the Rust side to avoid any pitfalls
|
||||
return { red: input.red * 255, green: input.green * 255, blue: input.blue * 255, alpha: input.alpha };
|
||||
}
|
||||
|
||||
export interface UpdateWorkingColors {
|
||||
primary: Color;
|
||||
secondary: Color;
|
||||
}
|
||||
function newUpdateWorkingColors(input: any): UpdateWorkingColors {
|
||||
return {
|
||||
primary: newColor(input.primary),
|
||||
secondary: newColor(input.secondary),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetActiveTool {
|
||||
tool_name: string;
|
||||
}
|
||||
function newSetActiveTool(input: any): SetActiveTool {
|
||||
return {
|
||||
tool_name: input.tool_name,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetActiveDocument {
|
||||
document_index: number;
|
||||
}
|
||||
function newSetActiveDocument(input: any): SetActiveDocument {
|
||||
return {
|
||||
document_index: input.document_index,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DisplayConfirmationToCloseDocument {
|
||||
document_index: number;
|
||||
}
|
||||
function newDisplayConfirmationToCloseDocument(input: any): DisplayConfirmationToCloseDocument {
|
||||
return {
|
||||
document_index: input.document_index,
|
||||
};
|
||||
}
|
||||
|
||||
function newDisplayConfirmationToCloseAllDocuments(_input: any): {} {
|
||||
return {};
|
||||
}
|
||||
|
||||
export interface UpdateCanvas {
|
||||
document: string;
|
||||
}
|
||||
function newUpdateCanvas(input: any): UpdateCanvas {
|
||||
return {
|
||||
document: input.document,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExportDocument {
|
||||
document: string;
|
||||
}
|
||||
function newExportDocument(input: any): UpdateCanvas {
|
||||
return {
|
||||
document: input.document,
|
||||
};
|
||||
}
|
||||
|
||||
export type DocumentChanged = {};
|
||||
function newDocumentChanged(_: any): DocumentChanged {
|
||||
return {};
|
||||
}
|
||||
|
||||
export interface CollapseFolder {
|
||||
path: BigUint64Array;
|
||||
}
|
||||
function newCollapseFolder(input: any): CollapseFolder {
|
||||
return {
|
||||
path: newPath(input.path),
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateLayer {
|
||||
path: BigUint64Array;
|
||||
data: LayerPanelEntry;
|
||||
}
|
||||
function newUpdateLayer(input: any): UpdateLayer {
|
||||
return {
|
||||
path: newPath(input.data.path),
|
||||
data: newLayerPanelEntry(input.data),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExpandFolder {
|
||||
path: BigUint64Array;
|
||||
children: Array<LayerPanelEntry>;
|
||||
}
|
||||
function newExpandFolder(input: any): ExpandFolder {
|
||||
return {
|
||||
path: newPath(input.path),
|
||||
children: input.children.map((child: any) => newLayerPanelEntry(child)),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetCanvasZoom {
|
||||
new_zoom: number;
|
||||
}
|
||||
function newSetCanvasZoom(input: any): SetCanvasZoom {
|
||||
return {
|
||||
new_zoom: input.new_zoom,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetCanvasRotation {
|
||||
new_radians: number;
|
||||
}
|
||||
function newSetCanvasRotation(input: any): SetCanvasRotation {
|
||||
return {
|
||||
new_radians: input.new_radians,
|
||||
};
|
||||
}
|
||||
|
||||
function newPath(input: any): BigUint64Array {
|
||||
// eslint-disable-next-line
|
||||
const u32CombinedPairs = input.map((n: Array<bigint>) => BigInt((BigInt(n[0]) << BigInt(32)) | BigInt(n[1])));
|
||||
return new BigUint64Array(u32CombinedPairs);
|
||||
}
|
||||
|
||||
export enum BlendMode {
|
||||
Normal = "normal",
|
||||
Multiply = "multiply",
|
||||
Darken = "darken",
|
||||
ColorBurn = "color-burn",
|
||||
Screen = "screen",
|
||||
Lighten = "lighten",
|
||||
ColorDodge = "color-dodge",
|
||||
Overlay = "overlay",
|
||||
SoftLight = "soft-light",
|
||||
HardLight = "hard-light",
|
||||
Difference = "difference",
|
||||
Exclusion = "exclusion",
|
||||
Hue = "hue",
|
||||
Saturation = "saturation",
|
||||
Color = "color",
|
||||
Luminosity = "luminosity",
|
||||
}
|
||||
function newBlendMode(input: string): BlendMode {
|
||||
const blendMode = {
|
||||
Normal: BlendMode.Normal,
|
||||
Multiply: BlendMode.Multiply,
|
||||
Darken: BlendMode.Darken,
|
||||
ColorBurn: BlendMode.ColorBurn,
|
||||
Screen: BlendMode.Screen,
|
||||
Lighten: BlendMode.Lighten,
|
||||
ColorDodge: BlendMode.ColorDodge,
|
||||
Overlay: BlendMode.Overlay,
|
||||
SoftLight: BlendMode.SoftLight,
|
||||
HardLight: BlendMode.HardLight,
|
||||
Difference: BlendMode.Difference,
|
||||
Exclusion: BlendMode.Exclusion,
|
||||
Hue: BlendMode.Hue,
|
||||
Saturation: BlendMode.Saturation,
|
||||
Color: BlendMode.Color,
|
||||
Luminosity: BlendMode.Luminosity,
|
||||
}[input];
|
||||
|
||||
if (!blendMode) throw new Error(`Invalid blend mode "${blendMode}"`);
|
||||
|
||||
return blendMode;
|
||||
}
|
||||
|
||||
function newOpacity(input: number): number {
|
||||
return input * 100;
|
||||
}
|
||||
|
||||
export interface LayerPanelEntry {
|
||||
name: string;
|
||||
visible: boolean;
|
||||
blend_mode: BlendMode;
|
||||
opacity: number;
|
||||
layer_type: LayerType;
|
||||
path: BigUint64Array;
|
||||
layer_data: LayerData;
|
||||
thumbnail: string;
|
||||
}
|
||||
function newLayerPanelEntry(input: any): LayerPanelEntry {
|
||||
return {
|
||||
name: input.name,
|
||||
visible: input.visible,
|
||||
blend_mode: newBlendMode(input.blend_mode),
|
||||
opacity: newOpacity(input.opacity),
|
||||
layer_type: newLayerType(input.layer_type),
|
||||
layer_data: newLayerData(input.layer_data),
|
||||
path: newPath(input.path),
|
||||
thumbnail: input.thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LayerData {
|
||||
expanded: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
function newLayerData(input: any): LayerData {
|
||||
return {
|
||||
expanded: input.expanded,
|
||||
selected: input.selected,
|
||||
};
|
||||
}
|
||||
|
||||
export enum LayerType {
|
||||
Folder = "Folder",
|
||||
Shape = "Shape",
|
||||
Circle = "Circle",
|
||||
Rect = "Rect",
|
||||
Line = "Line",
|
||||
PolyLine = "PolyLine",
|
||||
Ellipse = "Ellipse",
|
||||
}
|
||||
function newLayerType(input: any): LayerType {
|
||||
switch (input) {
|
||||
case "Folder":
|
||||
return LayerType.Folder;
|
||||
case "Shape":
|
||||
return LayerType.Shape;
|
||||
case "Circle":
|
||||
return LayerType.Circle;
|
||||
case "Rect":
|
||||
return LayerType.Rect;
|
||||
case "Line":
|
||||
return LayerType.Line;
|
||||
case "PolyLine":
|
||||
return LayerType.PolyLine;
|
||||
case "Ellipse":
|
||||
return LayerType.Ellipse;
|
||||
default:
|
||||
throw Error(`Received invalid input as an enum variant for LayerType: ${input}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user