mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-23 09:28:11 +08:00
Port frontend from Vue to Svelte (WIP in separate folder, many bugs) (#964)
* Make `tauri build` just work * Move folder: frontend/wasm -> wasm * Create SvelteKit project with 'npx create-svelte' * Move wasm-communication into seperate npm package * Use wasm-pack directly and pack package.json * Got it to work * Add primitive build script for wasm npm module * Fix wasm build script (python) * Clean up glue code * Rewrite wasm build script in node.js * Add serde-reflection to trace types * Add traced types * Generate typescript (.d.ts) from Rust types * Update .d.ts * Finalize TS types * Add script to update .d.ts * Add watch command to build wasm-bindgen * Make wasm work again * Add sass; fix build script for windows * Describe requirement for wasm-pack * Add license * Copy and reorganize vue components * translate LayoutCol.vue * Split app.scss into pieces * Translate LayoutRow.svelte * Rename scss files * Fix compile issues on Windows * WIP port TitleBar * Support classes for LayoutCol/Row * Restructure based on Vue codebase * Port all components in window folder * Port FloatingMenu * Port Document panel component * Update readme after folder move * Update typegen: print discriminant by default * Update typegen: Merge from branch 'tailwind' 4f14fedb Fixes bigint & bytes * Made Vue/webpack/eslint to accept wasm package at new location This is quite a hack. Those two packages are both named the same. Yes, it's an npm package inside another npm package. - frontend/src/wasm-communication/ - frontend-svelte/glue/ 'wasm/pkg/index.js' imports the correct one registered when linking. * Port LayerTree * Port NodeGraph * Port Properties * Port components in /floating-menus * Finish porting all Vue -> Svelte components * Change import prefix * Revert type generation * Revert moved wasm folder * Revert all of @locriacyber's work on this branch - Remove Vite and restore Webpack - Remove SvelteKit - Remove everything except the components I ported to Svelte - Restore all frontend files from Vue code, now altered for Svelte * Convert Vue's 'reactive' and 'provide' to Svelte's stores and contexts * Fix event emitting and bi-di data flow * Undo removal of 'update:' events * Fix 'update:' event dispatching * Fix usage in parent of bi-di component props * Fix component typing, more progress towards no errors * The page builds and opens! * Add loading spinner and remove postcss dependency * Make the basics of document editing work * Fix rebase history Co-authored-by: Locria Cyber <74560659+locriacyber@users.noreply.github.com>
This commit is contained in:
co-authored by
Locria Cyber
parent
2f7c45771e
commit
dcc3eadf44
@@ -0,0 +1,299 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, setContext } from "svelte";
|
||||
|
||||
import { type createEditor } from "@/wasm-communication/editor";
|
||||
import { operatingSystem } from "@/utility-functions/platform";
|
||||
import { createClipboardManager } from "@/io-managers/clipboard";
|
||||
import { createDragManager } from "@/io-managers/drag";
|
||||
import { createHyperlinkManager } from "@/io-managers/hyperlinks";
|
||||
import { createInputManager } from "@/io-managers/input";
|
||||
import { createLocalizationManager } from "@/io-managers/localization";
|
||||
import { createPanicManager } from "@/io-managers/panic";
|
||||
import { createPersistenceManager } from "@/io-managers/persistence";
|
||||
import { createDialogState } from "@/state-providers/dialog";
|
||||
import { createDocumentState } from "@/state-providers/document";
|
||||
import { createFontsState } from "@/state-providers/fonts";
|
||||
import { createFullscreenState } from "@/state-providers/fullscreen";
|
||||
import { createNodeGraphState } from "@/state-providers/node-graph";
|
||||
import { createPortfolioState } from "@/state-providers/portfolio";
|
||||
import { createWorkspaceState } from "@/state-providers/workspace";
|
||||
|
||||
import MainWindow from "@/components/window/MainWindow.svelte";
|
||||
|
||||
// Graphite WASM editor instance
|
||||
export let editor: ReturnType<typeof createEditor>;
|
||||
setContext("editor", editor);
|
||||
|
||||
// State provider systems
|
||||
let dialog = createDialogState(editor);
|
||||
setContext("dialog", dialog);
|
||||
let document = createDocumentState(editor);
|
||||
setContext("document", document);
|
||||
let fonts = createFontsState(editor);
|
||||
setContext("fonts", fonts);
|
||||
let fullscreen = createFullscreenState(editor);
|
||||
setContext("fullscreen", fullscreen);
|
||||
let nodeGraph = createNodeGraphState(editor);
|
||||
setContext("nodeGraph", nodeGraph);
|
||||
let portfolio = createPortfolioState(editor);
|
||||
setContext("portfolio", portfolio);
|
||||
let workspace = createWorkspaceState(editor);
|
||||
setContext("workspace", workspace);
|
||||
|
||||
// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
|
||||
createClipboardManager(editor);
|
||||
createHyperlinkManager(editor);
|
||||
createLocalizationManager(editor);
|
||||
createPanicManager(editor, dialog);
|
||||
createPersistenceManager(editor, portfolio);
|
||||
let dragManagerDestructor = createDragManager();
|
||||
let inputManagerDestructor = createInputManager(editor, dialog, portfolio, fullscreen);
|
||||
|
||||
onMount(() => {
|
||||
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready
|
||||
editor.instance.initAfterFrontendReady(operatingSystem());
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Call the destructor for each manager
|
||||
dragManagerDestructor();
|
||||
inputManagerDestructor();
|
||||
});
|
||||
</script>
|
||||
|
||||
<MainWindow />
|
||||
|
||||
<style lang="scss" global>
|
||||
// Disable the spinning loading indicator
|
||||
body::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
// Replace usage of `-rgb` variants 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
|
||||
--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-data-general: #c5c5c5;
|
||||
--color-data-general-dim: #767676;
|
||||
--color-data-vector: #65bbe5;
|
||||
--color-data-vector-dim: #4b778c;
|
||||
--color-data-raster: #e4bb72;
|
||||
--color-data-raster-dim: #8b7752;
|
||||
--color-data-mask: #8d85c7;
|
||||
--color-data-number: #d6536e;
|
||||
--color-data-number-dim: #803242;
|
||||
--color-data-vec2: #cc00ff;
|
||||
--color-data-vec2-dim: #71008d;
|
||||
--color-data-color: #70a898;
|
||||
--color-data-color-dim: #43645b;
|
||||
|
||||
--color-none: white;
|
||||
--color-none-repeat: no-repeat;
|
||||
--color-none-position: center center;
|
||||
// 24px tall, 48px wide
|
||||
--color-none-size-24px: 60px 24px;
|
||||
--color-none-image-24px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 24"><line stroke="red" stroke-width="4px" x1="0" y1="27" x2="60" y2="-3" /></svg>');
|
||||
// 32px tall, 64px wide
|
||||
--color-none-size-32px: 80px 32px;
|
||||
--color-none-image-32px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 32"><line stroke="red" stroke-width="4px" x1="0" y1="36" x2="80" y2="-4" /></svg>');
|
||||
|
||||
--color-transparent-checkered-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);
|
||||
--color-transparent-checkered-background-size: 16px 16px;
|
||||
--color-transparent-checkered-background-position: 0 0, 8px 8px;
|
||||
|
||||
--icon-expand-collapse-arrow: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23eee" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
|
||||
--icon-expand-collapse-arrow-hover: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23fff" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--color-2-mildblack);
|
||||
overscroll-behavior: none;
|
||||
-webkit-user-select: none; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// The default value of `auto` from the CSS spec is a footgun with flexbox layouts:
|
||||
// https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size
|
||||
* {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font-family: "Source Sans Pro", Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
svg,
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sharp-right-corners.sharp-right-corners.sharp-right-corners.sharp-right-corners {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.layout-row,
|
||||
.layout-col {
|
||||
.scrollable-x,
|
||||
.scrollable-y {
|
||||
// Firefox (standardized in CSS, but less capable)
|
||||
scrollbar-width: thin;
|
||||
scrollbar-width: 6px;
|
||||
scrollbar-gutter: 6px;
|
||||
scrollbar-color: var(--color-5-dullgray) transparent;
|
||||
|
||||
&:not(:hover) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
// WebKit (only in Chromium/Safari but more capable)
|
||||
&::-webkit-scrollbar {
|
||||
width: calc(2px + 6px + 2px);
|
||||
height: calc(2px + 6px + 2px);
|
||||
}
|
||||
|
||||
&:not(:hover)::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 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);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable-x.scrollable-y {
|
||||
// Standard
|
||||
overflow: auto;
|
||||
// WebKit
|
||||
overflow: overlay;
|
||||
}
|
||||
|
||||
.scrollable-x:not(.scrollable-y) {
|
||||
// Standard
|
||||
overflow: auto hidden;
|
||||
// WebKit
|
||||
overflow-x: overlay;
|
||||
}
|
||||
|
||||
.scrollable-y:not(.scrollable-x) {
|
||||
// Standard
|
||||
overflow: hidden auto;
|
||||
// WebKit
|
||||
overflow-y: overlay;
|
||||
}
|
||||
}
|
||||
|
||||
// List of all elements that should show an outline when focused by tabbing or by clicking the element
|
||||
.dropdown-input .dropdown-box,
|
||||
.font-input .dropdown-box {
|
||||
&:focus {
|
||||
outline: 1px dashed var(--color-e-nearwhite);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
// List of all elements that should show an outline when focused by tabbing, but not by clicking the element
|
||||
.icon-button,
|
||||
.text-button,
|
||||
.popover-button,
|
||||
.color-input > button,
|
||||
.color-picker .preset-color,
|
||||
.swatch-pair .swatch > button,
|
||||
.radio-input button,
|
||||
.menu-list,
|
||||
.menu-bar-input .entry,
|
||||
.layer-tree .expand-arrow,
|
||||
.widget-section .header {
|
||||
&:focus-visible {
|
||||
outline: 1px dashed var(--color-e-nearwhite);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
// Variant: dark outline over light colors
|
||||
&.preset-color.white,
|
||||
&.text-button.emphasized {
|
||||
&:focus-visible {
|
||||
outline: 1px dashed var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Checkbox needs to apply the focus outline to its sibling label
|
||||
.checkbox-input input:focus-visible + label {
|
||||
outline: 1px dashed var(--color-e-nearwhite);
|
||||
outline-offset: -1px;
|
||||
|
||||
// Variant: dark outline over light colors
|
||||
&.checked {
|
||||
outline: 1px dashed var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
# Overview of `/frontend-svelte/src/components/`
|
||||
|
||||
Each component represents a (usually reusable) part of the Graphite Editor GUI. These all get mounted within the Vue entry point, `App.svelte`, in the `/src` directory above this one.
|
||||
|
||||
## Floating Menus: `floating-menus/`
|
||||
|
||||
The temporary UI areas with dark backgrounds which hover over the top of the editor window content. Examples include popovers, dropdown menu selectors, and dialog modals.
|
||||
|
||||
## Layout: `layout/`
|
||||
|
||||
Useful containers that control the flow of content held within.
|
||||
|
||||
## Panels: `panels/`
|
||||
|
||||
The dockable tabbed regions like the Document, Properties, Layer Tree, and Node Graph panels.
|
||||
|
||||
## Widgets: `widgets/`
|
||||
|
||||
The interactive input items used to display information and provide user control.
|
||||
|
||||
## Window: `window/`
|
||||
|
||||
The building blocks for the Title Bar, Workspace, and Status Bar within an editor application window.
|
||||
|
||||
# Vue tips and tricks
|
||||
|
||||
This section contains a growing list of quick reference information for helpful Vue solutions and best practices. Feel free to add to this to help contributors learn things, or yourself remember tricks you'll likely forget in a few months.
|
||||
|
||||
## Bi-directional props
|
||||
|
||||
The component declares this:
|
||||
|
||||
```ts
|
||||
export default defineComponent({
|
||||
emits: ["update:theBidirectionalProperty"],
|
||||
props: {
|
||||
theBidirectionalProperty: {
|
||||
type: Number as PropType<number>,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// Called only when `theBidirectionalProperty` is changed from outside this component (with v-model)
|
||||
theBidirectionalProperty(newSelectedIndex: number | undefined) {},
|
||||
},
|
||||
methods: {
|
||||
doSomething() {
|
||||
this.$emit("update:theBidirectionalProperty", SOME_NEW_VALUE);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Users of the component do this for `theCorrespondingDataEntry` to be a two-way binding:
|
||||
|
||||
```html
|
||||
<DropdownInput v-model:theBidirectionalProperty="theCorrespondingDataEntry" />
|
||||
```
|
||||
@@ -0,0 +1,615 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, createEventDispatcher, getContext } from "svelte";
|
||||
|
||||
import { clamp } from "@/utility-functions/math";
|
||||
import { type HSV, type RGB } from "@/wasm-communication/messages";
|
||||
import { Color } from "@/wasm-communication/messages";
|
||||
|
||||
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import DropdownInput from "@/components/widgets/inputs/DropdownInput.svelte";
|
||||
import NumberInput from "@/components/widgets/inputs/NumberInput.svelte";
|
||||
import TextInput from "@/components/widgets/inputs/TextInput.svelte";
|
||||
import Separator from "@/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
type PresetColors = "none" | "black" | "white" | "red" | "yellow" | "green" | "cyan" | "blue" | "magenta";
|
||||
|
||||
const PURE_COLORS: Record<PresetColors, [number, number, number]> = {
|
||||
none: [0, 0, 0],
|
||||
black: [0, 0, 0],
|
||||
white: [1, 1, 1],
|
||||
red: [1, 0, 0],
|
||||
yellow: [1, 1, 0],
|
||||
green: [0, 1, 0],
|
||||
cyan: [0, 1, 1],
|
||||
blue: [0, 0, 1],
|
||||
magenta: [1, 0, 1],
|
||||
};
|
||||
const COLOR_SPACE_CHOICES = [[{ label: "sRGB" }]];
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
// emits: ["update:color", "update:open"],
|
||||
const dispatch = createEventDispatcher<{ color: Color }>();
|
||||
|
||||
export let color: Color;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement this
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
// TODO: See if this should be made to follow the pattern of DropdownInput.svelte so this could be removed
|
||||
export let open: boolean;
|
||||
|
||||
const hsvaOrNone = color.toHSVA();
|
||||
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
|
||||
|
||||
let hue = hsva.h;
|
||||
let saturation = hsva.s;
|
||||
let value = hsva.v;
|
||||
let alpha = hsva.a;
|
||||
let isNone = hsvaOrNone === undefined;
|
||||
let initialHue = hsva.h;
|
||||
let initialSaturation = hsva.s;
|
||||
let initialValue = hsva.v;
|
||||
let initialAlpha = hsva.a;
|
||||
let initialIsNone = hsvaOrNone === undefined;
|
||||
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
|
||||
let colorSpaceChoices = COLOR_SPACE_CHOICES;
|
||||
let strayCloses = true;
|
||||
|
||||
$: rgbChannels = Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][];
|
||||
$: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][];
|
||||
$: opaqueHueColor = new Color({ h: hue, s: 1, v: 1, a: 1 });
|
||||
$: newColor = isNone ? new Color("none") : new Color({ h: hue, s: saturation, v: value, a: alpha });
|
||||
$: initialColor = initialIsNone ? new Color("none") : new Color({ h: initialHue, s: initialSaturation, v: initialValue, a: initialAlpha });
|
||||
|
||||
$: watchOpen(open);
|
||||
$: watchColor(color);
|
||||
|
||||
// Called only when `open` is changed from outside this component (with v-model)
|
||||
function watchOpen(open: boolean) {
|
||||
if (open) setInitialHSVA(hue, saturation, value, alpha, isNone);
|
||||
}
|
||||
|
||||
// Called only when `color` is changed from outside this component (with v-model)
|
||||
function watchColor(color: Color) {
|
||||
const hsva = color.toHSVA();
|
||||
|
||||
if (hsva === undefined) {
|
||||
setNewHSVA(0, 0, 0, 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the hue, but only if it is necessary so we don't:
|
||||
// - ...jump the user's hue from 360° (top) to the equivalent 0° (bottom)
|
||||
// - ...reset the hue to 0° if the color is fully desaturated, where all hues are equivalent
|
||||
// - ...reset the hue to 0° if the color's value is black, where all hues are equivalent
|
||||
if (!(hsva.h === 0 && hue === 1) && hsva.s > 0 && hsva.v > 0) hue = hsva.h;
|
||||
// Update the saturation, but only if it is necessary so we don't:
|
||||
// - ...reset the saturation to the left is the color's value is black along the bottom edge, where all saturations are equivalent
|
||||
if (hsva.v !== 0) saturation = hsva.s;
|
||||
// Update the value
|
||||
value = hsva.v;
|
||||
// Update the alpha
|
||||
alpha = hsva.a;
|
||||
// Update the status of this not being a color
|
||||
isNone = false;
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const target = (e.target || undefined) as HTMLElement | undefined;
|
||||
draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
|
||||
|
||||
addEvents();
|
||||
|
||||
onPointerMove(e);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) removeEvents();
|
||||
|
||||
if (draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
|
||||
value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
strayCloses = false;
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
strayCloses = false;
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
alpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
strayCloses = false;
|
||||
}
|
||||
|
||||
const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
|
||||
setColor(color);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
draggingPickerTrack = undefined;
|
||||
strayCloses = true;
|
||||
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
function setColor(color?: Color) {
|
||||
const colorToEmit = color || new Color({ h: hue, s: saturation, v: value, a: alpha });
|
||||
dispatch("color", colorToEmit);
|
||||
}
|
||||
|
||||
function swapNewWithInitial() {
|
||||
const initial = initialColor;
|
||||
|
||||
const tempHue = hue;
|
||||
const tempSaturation = saturation;
|
||||
const tempValue = value;
|
||||
const tempAlpha = alpha;
|
||||
const tempIsNone = isNone;
|
||||
|
||||
setNewHSVA(initialHue, initialSaturation, initialValue, initialAlpha, initialIsNone);
|
||||
setInitialHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone);
|
||||
|
||||
setColor(initial);
|
||||
}
|
||||
|
||||
function setColorCode(colorCode: string) {
|
||||
const color = Color.fromCSS(colorCode);
|
||||
if (color) setColor(color);
|
||||
}
|
||||
|
||||
function setColorRGB(channel: keyof RGB, strength: number | undefined) {
|
||||
// Do nothing if the given value is undefined
|
||||
if (strength === undefined) undefined;
|
||||
// Set the specified channel to the given value
|
||||
else if (channel === "r") setColor(new Color(strength / 255, newColor.green, newColor.blue, newColor.alpha));
|
||||
else if (channel === "g") setColor(new Color(newColor.red, strength / 255, newColor.blue, newColor.alpha));
|
||||
else if (channel === "b") setColor(new Color(newColor.red, newColor.green, strength / 255, newColor.alpha));
|
||||
}
|
||||
|
||||
function setColorHSV(channel: keyof HSV, strength: number | undefined) {
|
||||
// Do nothing if the given value is undefined
|
||||
if (strength === undefined) undefined;
|
||||
// Set the specified channel to the given value
|
||||
else if (channel === "h") hue = strength / 360;
|
||||
else if (channel === "s") saturation = strength / 100;
|
||||
else if (channel === "v") value = strength / 100;
|
||||
|
||||
setColor();
|
||||
}
|
||||
|
||||
function setColorAlphaPercent(strength: number | undefined) {
|
||||
if (strength !== undefined) alpha = strength / 100;
|
||||
setColor();
|
||||
}
|
||||
|
||||
function setColorPresetSubtile(e: MouseEvent) {
|
||||
const clickedTile = e.target as HTMLDivElement | undefined;
|
||||
const tileColor = clickedTile?.getAttribute("data-pure-tile") || undefined;
|
||||
|
||||
if (tileColor) setColorPreset(tileColor as PresetColors);
|
||||
}
|
||||
|
||||
function setColorPreset(preset: PresetColors) {
|
||||
if (preset === "none") {
|
||||
setNewHSVA(0, 0, 0, 1, true);
|
||||
setColor(new Color("none"));
|
||||
return;
|
||||
}
|
||||
|
||||
const presetColor = new Color(...PURE_COLORS[preset], 1);
|
||||
const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 };
|
||||
|
||||
setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false);
|
||||
setColor(presetColor);
|
||||
}
|
||||
|
||||
function setNewHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
|
||||
hue = hue;
|
||||
saturation = saturation;
|
||||
value = value;
|
||||
alpha = alpha;
|
||||
isNone = isNone;
|
||||
}
|
||||
|
||||
function setInitialHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
|
||||
initialHue = hue;
|
||||
initialSaturation = saturation;
|
||||
initialValue = value;
|
||||
initialAlpha = alpha;
|
||||
initialIsNone = isNone;
|
||||
}
|
||||
|
||||
async function activateEyedropperSample() {
|
||||
// TODO: Replace this temporary solution that only works in Chromium-based browsers with the custom color sampler used by the Eyedropper tool
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(window as any).EyeDropper) {
|
||||
editor.instance.eyedropperSampleForColorPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await new (window as any).EyeDropper().open();
|
||||
setColorCode(result.sRGBHex);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FloatingMenu class="color-picker" {open} on:open {strayCloses} {direction} type="Popover">
|
||||
<LayoutRow
|
||||
styles={{
|
||||
"--new-color": newColor.toHexOptionalAlpha(),
|
||||
"--new-color-contrasting": newColor.contrastingColor(),
|
||||
"--initial-color": initialColor.toHexOptionalAlpha(),
|
||||
"--initial-color-contrasting": initialColor.contrastingColor(),
|
||||
"--hue-color": opaqueHueColor.toRgbCSS(),
|
||||
"--hue-color-contrasting": opaqueHueColor.contrastingColor(),
|
||||
"--opaque-color": (newColor.opaque() || new Color(0, 0, 0, 1)).toHexNoAlpha(),
|
||||
"--opaque-color-contrasting": (newColor.opaque() || new Color(0, 0, 0, 1)).contrastingColor(),
|
||||
}}
|
||||
>
|
||||
<LayoutCol class="saturation-value-picker" on:pointerdown={onPointerDown} data-saturation-value-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="hue-picker" on:pointerdown={onPointerDown} data-hue-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-pincers" style:top={`${(1 - hue) * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="alpha-picker" on:pointerdown={onPointerDown} data-alpha-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-pincers" style:top={`${(1 - alpha) * 100}%`} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="details">
|
||||
<LayoutRow class="choice-preview" on:click={swapNewWithInitial} tooltip="Comparison views of the present color choice (left) and the color before any change (right). Click to swap sides.">
|
||||
<LayoutCol class="new-color" classes={{ none: isNone }}>
|
||||
<TextLabel>New</TextLabel>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="initial-color" classes={{ none: initialIsNone }}>
|
||||
<TextLabel>Initial</TextLabel>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
<DropdownInput entries={colorSpaceChoices} selectedIndex={0} disabled={true} tooltip="Color Space and HDR (coming soon)" />
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Color code in hexadecimal format">Hex</TextLabel>
|
||||
<Separator />
|
||||
<LayoutRow>
|
||||
<TextInput
|
||||
value={newColor.toHexOptionalAlpha() || "-"}
|
||||
on:commitText={({ detail }) => setColorCode(detail)}
|
||||
centered={true}
|
||||
tooltip="Color code in hexadecimal format. 6 digits if opaque, 8 with alpha.\nAccepts input of CSS color values including named colors."
|
||||
/>
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Red/Green/Blue channels of the color, integers 0–255">RGB</TextLabel>
|
||||
<Separator />
|
||||
<LayoutRow>
|
||||
{#each rgbChannels as [channel, strength], index (channel)}
|
||||
{#if index > 0}
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
<NumberInput
|
||||
value={strength}
|
||||
on:value={({ detail }) => setColorRGB(channel, detail)}
|
||||
min={0}
|
||||
max={255}
|
||||
minWidth={56}
|
||||
tooltip={`${{ r: "Red", g: "Green", b: "Blue" }[channel]} channel, integers 0–255`}
|
||||
/>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
<LayoutRow>
|
||||
<TextLabel tooltip="Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model."
|
||||
>HSV</TextLabel
|
||||
>
|
||||
<Separator />
|
||||
<LayoutRow>
|
||||
{#each hsvChannels as [channel, strength], index (channel)}
|
||||
{#if index > 0}
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
<NumberInput
|
||||
value={strength}
|
||||
on:value={({ detail }) => setColorHSV(channel, detail)}
|
||||
min={0}
|
||||
max={channel === "h" ? 360 : 100}
|
||||
unit={channel === "h" ? "°" : "%"}
|
||||
minWidth={56}
|
||||
tooltip={{
|
||||
h: "Hue component, the "color" along the rainbow",
|
||||
s: "Saturation component, the "colorfulness" from gray to vivid",
|
||||
v: "Value (or Brightness), the distance away from being darkened to black",
|
||||
}[channel]}
|
||||
/>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
<NumberInput
|
||||
label="Alpha"
|
||||
value={!isNone ? alpha * 100 : undefined}
|
||||
on:value={({ detail }) => setColorAlphaPercent(detail)}
|
||||
min={0}
|
||||
max={100}
|
||||
rangeMin={0}
|
||||
rangeMax={100}
|
||||
unit="%"
|
||||
mode="Range"
|
||||
tooltip={`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`}
|
||||
/>
|
||||
<LayoutRow class="leftover-space" />
|
||||
<LayoutRow>
|
||||
{#if allowNone}
|
||||
<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set none" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set black" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set white" tabindex="0" />
|
||||
<Separator type="Related" />
|
||||
<button class="preset-color pure" on:click={setColorPresetSubtile} tabindex="-1">
|
||||
<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set red" />
|
||||
<div data-pure-tile="yellow" style="--pure-color: #ffff00; --pure-color-gray: #e3e3e3" title="Set yellow" />
|
||||
<div data-pure-tile="green" style="--pure-color: #00ff00; --pure-color-gray: #969696" title="Set green" />
|
||||
<div data-pure-tile="cyan" style="--pure-color: #00ffff; --pure-color-gray: #b2b2b2" title="Set cyan" />
|
||||
<div data-pure-tile="blue" style="--pure-color: #0000ff; --pure-color-gray: #1c1c1c" title="Set blue" />
|
||||
<div data-pure-tile="magenta" style="--pure-color: #ff00ff; --pure-color-gray: #696969" title="Set magenta" />
|
||||
</button>
|
||||
<Separator type="Related" />
|
||||
<IconButton icon="Eyedropper" size={24} action={activateEyedropperSample} tooltip="Sample a pixel color from the document" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss" global>
|
||||
.color-picker {
|
||||
.saturation-value-picker {
|
||||
width: 256px;
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.saturation-value-picker,
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
height: 256px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hue-picker,
|
||||
.alpha-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%);
|
||||
--selection-pincers-color: var(--hue-color-contrasting);
|
||||
}
|
||||
|
||||
.alpha-picker {
|
||||
background: linear-gradient(to bottom, var(--opaque-color), transparent);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
position: relative;
|
||||
background: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
}
|
||||
--selection-pincers-color: var(--new-color-contrasting);
|
||||
}
|
||||
|
||||
.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 var(--opaque-color-contrasting);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.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 var(--selection-pincers-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent var(--selection-pincers-color) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-left: 16px;
|
||||
width: 208px;
|
||||
gap: 8px;
|
||||
|
||||
> .layout-row {
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
> .text-label {
|
||||
width: 24px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&.leftover-space {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.choice-preview {
|
||||
flex: 0 0 auto;
|
||||
width: 208px;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--color-0-black);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
.new-color {
|
||||
background: linear-gradient(var(--new-color), var(--new-color)), var(--color-transparent-checkered-background);
|
||||
|
||||
.text-label {
|
||||
text-align: left;
|
||||
margin: 2px 8px;
|
||||
color: var(--new-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.initial-color {
|
||||
background: linear-gradient(var(--initial-color), var(--initial-color)), var(--color-transparent-checkered-background);
|
||||
|
||||
.text-label {
|
||||
text-align: right;
|
||||
margin: 2px 8px;
|
||||
color: var(--initial-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.new-color,
|
||||
.initial-color {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
|
||||
&.none {
|
||||
background: var(--color-none);
|
||||
background-repeat: var(--color-none-repeat);
|
||||
background-position: var(--color-none-position);
|
||||
background-size: var(--color-none-size-32px);
|
||||
background-image: var(--color-none-image-32px);
|
||||
|
||||
.text-label {
|
||||
// Many stacked white shadows helps to increase the opacity and approximate shadow spread which does not exist for text shadows
|
||||
text-shadow: 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preset-color {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
width: calc(48px + (48px + 4px) / 2);
|
||||
height: 24px;
|
||||
|
||||
&.none {
|
||||
background: var(--color-none);
|
||||
background-repeat: var(--color-none-repeat);
|
||||
background-position: var(--color-none-position);
|
||||
background-size: var(--color-none-size-24px);
|
||||
background-image: var(--color-none-image-24px);
|
||||
|
||||
&,
|
||||
& ~ .black,
|
||||
& ~ .white {
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&.black {
|
||||
background: black;
|
||||
}
|
||||
|
||||
&.white {
|
||||
background: white;
|
||||
}
|
||||
|
||||
&.pure {
|
||||
width: 24px;
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.5s ease;
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
width: calc(100% / 3);
|
||||
height: 50%;
|
||||
// For the least jarring luminance conversion, these colors are derived by placing a black layer with the "desaturate" blend mode over the colors.
|
||||
// We don't use the CSS `filter: grayscale(1);` property because it produces overly dark tones for bright colors with a noticeable jump on hover.
|
||||
background: var(--pure-color-gray);
|
||||
}
|
||||
|
||||
&:hover div,
|
||||
&:focus div {
|
||||
background: var(--pure-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type DialogState } from "@/state-providers/dialog";
|
||||
|
||||
const dialog = getContext<DialogState>("dialog");
|
||||
|
||||
let dialogModal: FloatingMenu;
|
||||
|
||||
export function dismiss() {
|
||||
dialog.dismissDialog();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Focus the first button in the popup
|
||||
const emphasizedOrFirstButton = (dialogModal.div().querySelector("[data-emphasized]") || dialogModal.div().querySelector("[data-text-button]") || undefined) as HTMLButtonElement | undefined;
|
||||
emphasizedOrFirstButton?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FloatingMenu open={true} class="dialog-modal" type="Dialog" direction="Center" bind:this={dialogModal} data-dialog-modal>
|
||||
<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">
|
||||
{#if $dialog.widgets.layout.length > 0}
|
||||
<WidgetLayout layout={$dialog.widgets} class="details" />
|
||||
{/if}
|
||||
{#if ($dialog.jsCallbackBasedButtons?.length || NaN) > 0}
|
||||
<LayoutRow class="panic-buttons-row">
|
||||
{#each $dialog.jsCallbackBasedButtons || [] as button, index (index)}
|
||||
<TextButton action={() => button.callback?.()} {...button.props} />
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss" global>
|
||||
.dialog-modal {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
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 {
|
||||
margin: -4px 0;
|
||||
|
||||
.details.text-label {
|
||||
-webkit-user-select: text; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
||||
user-select: text;
|
||||
white-space: pre-wrap;
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.panic-buttons-row {
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script lang="ts" context="module">
|
||||
// Should be equal to the width and height of the canvas in the CSS
|
||||
const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110;
|
||||
// Should be equal to the width and height of the `.pixel-outline` div in the CSS, and should be evenly divisible into the number above
|
||||
const UPSCALE_FACTOR = 10;
|
||||
|
||||
export const ZOOM_WINDOW_DIMENSIONS = ZOOM_WINDOW_DIMENSIONS_EXPANDED / UPSCALE_FACTOR;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
|
||||
|
||||
const temporaryCanvas = document.createElement("canvas");
|
||||
|
||||
let zoomPreviewCanvas: HTMLCanvasElement;
|
||||
|
||||
export let imageData: ImageData | undefined = undefined;
|
||||
export let colorChoice: string;
|
||||
export let primaryColor: string;
|
||||
export let secondaryColor: string;
|
||||
export let x: number;
|
||||
export let y: number;
|
||||
|
||||
$: watchImageData(imageData);
|
||||
|
||||
function watchImageData(imageData: ImageData | undefined) {
|
||||
displayImageDataPreview(imageData);
|
||||
}
|
||||
|
||||
function displayImageDataPreview(imageData: ImageData | undefined) {
|
||||
zoomPreviewCanvas.width = ZOOM_WINDOW_DIMENSIONS;
|
||||
zoomPreviewCanvas.height = ZOOM_WINDOW_DIMENSIONS;
|
||||
const context = zoomPreviewCanvas.getContext("2d");
|
||||
|
||||
temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS;
|
||||
temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS;
|
||||
const temporaryContext = temporaryCanvas.getContext("2d");
|
||||
|
||||
if (!imageData || !context || !temporaryContext) return;
|
||||
|
||||
temporaryContext.putImageData(imageData, 0, 0, 0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||
|
||||
context.fillStyle = "black";
|
||||
context.fillRect(0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||
|
||||
context.drawImage(temporaryCanvas, 0, 0);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
displayImageDataPreview(imageData);
|
||||
});
|
||||
</script>
|
||||
|
||||
<FloatingMenu
|
||||
open={true}
|
||||
class="eyedropper-preview"
|
||||
type="Cursor"
|
||||
styles={{ "--ring-color-primary": primaryColor, "--ring-color-secondary": secondaryColor, "--ring-color-choice": colorChoice, left: x + "px", top: y + "px" }}
|
||||
>
|
||||
<div class="ring">
|
||||
<div class="canvas-container">
|
||||
<canvas bind:this={zoomPreviewCanvas} />
|
||||
<div class="pixel-outline" />
|
||||
</div>
|
||||
</div>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss" global>
|
||||
.eyedropper-preview {
|
||||
pointer-events: none;
|
||||
|
||||
.ring {
|
||||
transform: translate(0, -50%) rotate(45deg);
|
||||
position: relative;
|
||||
background: var(--ring-color-choice);
|
||||
padding: 16px;
|
||||
border: 8px solid;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--ring-color-primary);
|
||||
border-left-color: var(--ring-color-primary);
|
||||
border-bottom-color: var(--ring-color-secondary);
|
||||
border-right-color: var(--ring-color-secondary);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5), 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
transform: rotate(-45deg);
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 50%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5), inset 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.pixel-outline {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
--outline-width: 2;
|
||||
margin-top: calc(-1px * (var(--outline-width) / 2));
|
||||
width: calc(10px - (var(--outline-width) * 1px));
|
||||
height: calc(10px - var(--outline-width) * 1px);
|
||||
border: calc(var(--outline-width) * 1px) solid var(--color-0-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type MenuListEntry } from "@/wasm-communication/messages";
|
||||
|
||||
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import Separator from "@/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
|
||||
|
||||
let floatingMenu: FloatingMenu;
|
||||
let scroller: LayoutCol;
|
||||
|
||||
// emits: ["update:open", "update:activeEntry", "naturalWidth"],
|
||||
const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry }>();
|
||||
|
||||
export let entries: MenuListEntry[][];
|
||||
export let activeEntry: MenuListEntry | undefined = undefined;
|
||||
export let open: boolean;
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
export let minWidth = 0;
|
||||
export let drawIcon = false;
|
||||
export let interactive = false;
|
||||
export let scrollableY = false;
|
||||
export let virtualScrollingEntryHeight = 0;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
let isOpen = open;
|
||||
let highlighted = activeEntry as MenuListEntry | undefined;
|
||||
let virtualScrollingEntriesStart = 0;
|
||||
|
||||
// Called only when `open` is changed from outside this component (with v-model)
|
||||
$: watchOpen(open);
|
||||
$: dispatch("open", isOpen);
|
||||
$: watchEntries(entries, floatingMenu);
|
||||
$: watchDrawIcon(drawIcon, floatingMenu);
|
||||
$: virtualScrollingTotalHeight = entries.length === 0 ? 0 : entries[0].length * virtualScrollingEntryHeight;
|
||||
$: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
||||
$: virtualScrollingEndIndex = entries.length === 0 ? 0 : Math.min(entries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
|
||||
|
||||
function watchOpen(open: boolean) {
|
||||
isOpen = open;
|
||||
highlighted = activeEntry;
|
||||
}
|
||||
|
||||
// TODO: Svelte: fix infinite loop and reenable
|
||||
function watchEntries(_: MenuListEntry[][], floatingMenu: FloatingMenu) {
|
||||
// floatingMenu?.measureAndEmitNaturalWidth();
|
||||
}
|
||||
|
||||
// TODO: Svelte: fix infinite loop and reenable
|
||||
function watchDrawIcon(_: boolean, floatingMenu: FloatingMenu) {
|
||||
// floatingMenu?.measureAndEmitNaturalWidth();
|
||||
}
|
||||
|
||||
function onScroll(e: Event) {
|
||||
if (!virtualScrollingEntryHeight) return;
|
||||
virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
|
||||
}
|
||||
|
||||
function onEntryClick(menuListEntry: MenuListEntry): void {
|
||||
// Call the action if available
|
||||
if (menuListEntry.action) menuListEntry.action();
|
||||
|
||||
// Emit the clicked entry as the new active entry
|
||||
dispatch("activeEntry", menuListEntry);
|
||||
|
||||
// Close the containing menu
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
||||
dispatch("open", false);
|
||||
isOpen = false; // TODO: This is a hack for MenuBarInput submenus, remove it when we get rid of using `ref`
|
||||
}
|
||||
|
||||
function onEntryPointerEnter(menuListEntry: MenuListEntry): void {
|
||||
if (!menuListEntry.children?.length) return;
|
||||
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = true;
|
||||
else dispatch("open", true);
|
||||
}
|
||||
|
||||
function onEntryPointerLeave(menuListEntry: MenuListEntry): void {
|
||||
if (!menuListEntry.children?.length) return;
|
||||
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
||||
else dispatch("open", false);
|
||||
}
|
||||
|
||||
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
|
||||
if (!menuListEntry.children?.length) return false;
|
||||
|
||||
return open;
|
||||
}
|
||||
|
||||
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
|
||||
export function keydown(e: KeyboardEvent, submenu: boolean): boolean {
|
||||
// Interactive menus should keep the active entry the same as the highlighted one
|
||||
if (interactive) highlighted = activeEntry;
|
||||
|
||||
const menuOpen = isOpen;
|
||||
const flatEntries = entries.flat().filter((entry) => !entry.disabled);
|
||||
const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.isOpen);
|
||||
|
||||
const openSubmenu = (highlighted: MenuListEntry): void => {
|
||||
if (highlighted.ref && highlighted.children?.length) {
|
||||
highlighted.ref.isOpen = true;
|
||||
|
||||
// Highlight first item
|
||||
highlighted.ref.setHighlighted(highlighted.children[0][0]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
|
||||
// Allow opening menu with space or enter
|
||||
isOpen = true;
|
||||
highlighted = activeEntry;
|
||||
} else if (menuOpen && openChild >= 0) {
|
||||
// Redirect the keyboard navigation to a submenu if one is open
|
||||
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
|
||||
|
||||
// Highlight the menu item in the parent list that corresponds with the open submenu
|
||||
if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
|
||||
|
||||
// Handle the child closing the entire menu stack
|
||||
if (shouldCloseStack) {
|
||||
isOpen = false;
|
||||
return true;
|
||||
}
|
||||
} else if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||
// Navigate to the next and previous entries with arrow keys
|
||||
|
||||
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
|
||||
if (highlighted) {
|
||||
const index = highlighted ? flatEntries.map((entry) => entry.label).indexOf(highlighted.label) : 0;
|
||||
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
|
||||
|
||||
// Interactive dropdowns should lock at the end whereas other dropdowns should loop
|
||||
if (interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
|
||||
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
|
||||
}
|
||||
|
||||
const newEntry = flatEntries[newIndex];
|
||||
setHighlighted(newEntry);
|
||||
} else if (menuOpen && e.key === "Escape") {
|
||||
// Close menu with escape key
|
||||
isOpen = false;
|
||||
|
||||
// Reset active to before open
|
||||
setHighlighted(activeEntry);
|
||||
} else if (menuOpen && highlighted && e.key === "Enter") {
|
||||
// Handle clicking on an option if enter is pressed
|
||||
if (!highlighted.children?.length) onEntryClick(highlighted);
|
||||
else openSubmenu(highlighted);
|
||||
|
||||
// Stop the event from triggering a press on a new dialog
|
||||
e.preventDefault();
|
||||
|
||||
// Enter should close the entire menu stack
|
||||
return true;
|
||||
} else if (menuOpen && highlighted && e.key === "ArrowRight") {
|
||||
// Right arrow opens a submenu
|
||||
openSubmenu(highlighted);
|
||||
} else if (menuOpen && e.key === "ArrowLeft") {
|
||||
// Left arrow closes a submenu
|
||||
if (submenu) isOpen = false;
|
||||
}
|
||||
|
||||
// By default, keep the menu stack open
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
|
||||
highlighted = newHighlight;
|
||||
// Interactive menus should keep the active entry the same as the highlighted one
|
||||
if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
|
||||
}
|
||||
|
||||
// TODO: Svelte: Re-enable the `export` prefix
|
||||
export function scrollViewTo(distanceDown: number): void {
|
||||
scroller.div().scrollTo(0, distanceDown);
|
||||
}
|
||||
|
||||
export function menuIsOpen(): boolean {
|
||||
return open;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FloatingMenu
|
||||
class="menu-list"
|
||||
open={isOpen}
|
||||
on:open={({ detail }) => (isOpen = detail)}
|
||||
on:naturalWidth
|
||||
type="Dropdown"
|
||||
windowEdgeMargin={0}
|
||||
escapeCloses={false}
|
||||
{direction}
|
||||
{minWidth}
|
||||
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
||||
bind:this={floatingMenu}
|
||||
>
|
||||
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
|
||||
However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using $refs. -->
|
||||
<LayoutCol
|
||||
bind:this={scroller}
|
||||
scrollableY={scrollableY && virtualScrollingEntryHeight !== 0}
|
||||
on:scroll={onScroll}
|
||||
styles={{ "min-width": virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }}
|
||||
>
|
||||
{#if virtualScrollingEntryHeight}
|
||||
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
||||
{/if}
|
||||
{#each entries as section, sectionIndex (sectionIndex)}
|
||||
{#if sectionIndex > 0}
|
||||
<Separator type="List" direction="Vertical" />
|
||||
{/if}
|
||||
{#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0))}
|
||||
<LayoutRow
|
||||
class="row"
|
||||
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
|
||||
styles={{ height: virtualScrollingEntryHeight || "20px" }}
|
||||
{tooltip}
|
||||
on:click={() => !entry.disabled && onEntryClick(entry)}
|
||||
on:pointerenter={() => !entry.disabled && onEntryPointerEnter(entry)}
|
||||
on:pointerleave={() => !entry.disabled && onEntryPointerLeave(entry)}
|
||||
>
|
||||
{#if entry.icon && drawIcon}
|
||||
<IconLabel icon={entry.icon} class="entry-icon" />
|
||||
{:else if drawIcon}
|
||||
<div class="no-icon" />
|
||||
{/if}
|
||||
|
||||
{#if entry.font}
|
||||
<link rel="stylesheet" href={entry.font?.toString()} />
|
||||
{/if}
|
||||
|
||||
<TextLabel class="entry-label" styles={{ "font-family": `${!entry.font ? "inherit" : entry.value}` }}>{entry.label}</TextLabel>
|
||||
|
||||
{#if entry.shortcut?.keys.length}
|
||||
<UserInputLabel keysWithLabelsGroups={[entry.shortcut.keys]} requiresLock={entry.shortcutRequiresLock} />
|
||||
{/if}
|
||||
|
||||
{#if entry.children?.length}
|
||||
<div class="submenu-arrow" />
|
||||
{:else}
|
||||
<div class="no-submenu-arrow" />
|
||||
{/if}
|
||||
|
||||
{#if entry.children}
|
||||
<svelte:self on:naturalWidth open={entry.ref?.menuIsOpen() || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if virtualScrollingEntryHeight}
|
||||
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }} />
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</FloatingMenu>
|
||||
|
||||
<style lang="scss" global>
|
||||
.menu-list {
|
||||
.floating-menu-container .floating-menu-content {
|
||||
padding: 4px 0;
|
||||
|
||||
.separator div {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
.scroll-spacer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 20px;
|
||||
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-icon,
|
||||
.no-icon {
|
||||
margin: 0 4px;
|
||||
|
||||
& + .entry-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-input-label {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
.entry-icon svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-e-nearwhite);
|
||||
color: var(--color-2-mildblack);
|
||||
|
||||
.entry-icon svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--color-8-uppergray);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,575 @@
|
||||
<script lang="ts" context="module">
|
||||
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
|
||||
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { afterUpdate, createEventDispatcher, tick } from "svelte";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
|
||||
const POINTER_STRAY_DISTANCE = 100;
|
||||
|
||||
// emits: ["update:open", "naturalWidth"],
|
||||
const dispatch = createEventDispatcher<{ open: boolean; naturalWidth: number }>();
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let open: boolean;
|
||||
export let type: MenuType;
|
||||
export let direction: MenuDirection = "Bottom";
|
||||
export let windowEdgeMargin = 6;
|
||||
export let scrollableY = false;
|
||||
export let minWidth = 0;
|
||||
export let escapeCloses = true;
|
||||
export let strayCloses = true;
|
||||
|
||||
let tail: HTMLDivElement;
|
||||
let self: HTMLDivElement;
|
||||
let floatingMenuContainer: HTMLDivElement;
|
||||
let floatingMenuContent: LayoutCol;
|
||||
|
||||
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
|
||||
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
|
||||
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
|
||||
// This is the opposite concern of the natural width measurement system, which gets the natural width of the floating menu content in order for the
|
||||
// spawner widget to optionally set its min-size to the floating menu's natural width.
|
||||
let containerResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
resizeObserverCallback(entries);
|
||||
});
|
||||
let wasOpen = open;
|
||||
let measuringOngoing = false;
|
||||
let measuringOngoingGuard = false;
|
||||
let minWidthParentWidth = 0;
|
||||
let pointerStillDown = false;
|
||||
let workspaceBounds = new DOMRect();
|
||||
let floatingMenuBounds = new DOMRect();
|
||||
let floatingMenuContentBounds = new DOMRect();
|
||||
|
||||
$: minWidthStyleValue = measuringOngoing ? "0" : `${Math.max(minWidth, minWidthParentWidth)}px`;
|
||||
$: displayTail = open && type === "Popover";
|
||||
$: displayContainer = open || measuringOngoing;
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
$: watchOpenChange(open);
|
||||
|
||||
// Called only when `open` is changed from outside this component (with `v-model`)
|
||||
async function watchOpenChange(isOpen: boolean) {
|
||||
// Switching from closed to open
|
||||
if (isOpen && !wasOpen) {
|
||||
// TODO: Close any other floating menus that may already be open, which can happen using tab navigation and Enter/Space Bar to open
|
||||
|
||||
// Close floating menu if pointer strays far enough away
|
||||
window.addEventListener("pointermove", pointerMoveHandler);
|
||||
// Close floating menu if esc is pressed
|
||||
window.addEventListener("keydown", keyDownHandler);
|
||||
// Close floating menu if pointer is outside (but within stray distance)
|
||||
window.addEventListener("pointerdown", pointerDownHandler);
|
||||
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
|
||||
window.addEventListener("pointerup", pointerUpHandler);
|
||||
|
||||
// Floating menu min-width resize observer
|
||||
|
||||
await tick();
|
||||
|
||||
// Start a new observation of the now-open floating menu
|
||||
containerResizeObserver.disconnect();
|
||||
containerResizeObserver.observe(floatingMenuContainer);
|
||||
}
|
||||
|
||||
// Switching from open to closed
|
||||
if (!isOpen && wasOpen) {
|
||||
// Clean up observation of the now-closed floating menu
|
||||
containerResizeObserver.disconnect();
|
||||
|
||||
window.removeEventListener("pointermove", pointerMoveHandler);
|
||||
window.removeEventListener("keydown", keyDownHandler);
|
||||
window.removeEventListener("pointerdown", pointerDownHandler);
|
||||
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
|
||||
}
|
||||
|
||||
// Now that we're done reading the old state, update it to the current state for next time
|
||||
wasOpen = isOpen;
|
||||
}
|
||||
|
||||
// Gets the client bounds of the elements and apply relevant styles to them
|
||||
// TODO: Use the Vue :style attribute more whilst not causing recursive updates
|
||||
afterUpdate(() => {
|
||||
// Turning measuring on and off both cause the component to change, which causes the `updated()` Vue event to fire extraneous times (hurting performance and sometimes causing an infinite loop)
|
||||
if (measuringOngoingGuard) return;
|
||||
|
||||
positionAndStyleFloatingMenu();
|
||||
});
|
||||
|
||||
function resizeObserverCallback(entries: ResizeObserverEntry[]) {
|
||||
minWidthParentWidth = entries[0].contentRect.width;
|
||||
}
|
||||
|
||||
function positionAndStyleFloatingMenu() {
|
||||
if (type === "Cursor") return;
|
||||
|
||||
const workspace = document.querySelector("[data-workspace]");
|
||||
|
||||
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent) return;
|
||||
|
||||
workspaceBounds = workspace.getBoundingClientRect();
|
||||
floatingMenuBounds = self.getBoundingClientRect();
|
||||
const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect();
|
||||
floatingMenuContentBounds = floatingMenuContent.div().getBoundingClientRect();
|
||||
|
||||
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
|
||||
|
||||
if (!inParentFloatingMenu) {
|
||||
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
const tailOffset = type === "Popover" ? 10 : 0;
|
||||
if (direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + floatingMenuBounds.top}px`;
|
||||
if (direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + floatingMenuBounds.bottom}px`;
|
||||
if (direction === "Right") floatingMenuContent.style.left = `${tailOffset + floatingMenuBounds.left}px`;
|
||||
if (direction === "Left") floatingMenuContent.style.right = `${tailOffset + floatingMenuBounds.right}px`;
|
||||
|
||||
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.top}px`;
|
||||
if (tail && direction === "Top") tail.style.bottom = `${floatingMenuBounds.bottom}px`;
|
||||
if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.left}px`;
|
||||
if (tail && direction === "Left") tail.style.right = `${floatingMenuBounds.right}px`;
|
||||
}
|
||||
|
||||
type Edge = "Top" | "Bottom" | "Left" | "Right";
|
||||
let zeroedBorderVertical: Edge | undefined;
|
||||
let zeroedBorderHorizontal: Edge | undefined;
|
||||
|
||||
if (direction === "Top" || direction === "Bottom") {
|
||||
zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top";
|
||||
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
if (floatingMenuContentBounds.left - windowEdgeMargin <= workspaceBounds.left) {
|
||||
floatingMenuContent.style.left = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
|
||||
}
|
||||
if (floatingMenuContentBounds.right + windowEdgeMargin >= workspaceBounds.right) {
|
||||
floatingMenuContent.style.right = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right";
|
||||
}
|
||||
}
|
||||
if (direction === "Left" || direction === "Right") {
|
||||
zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left";
|
||||
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
if (floatingMenuContentBounds.top - windowEdgeMargin <= workspaceBounds.top) {
|
||||
floatingMenuContent.style.top = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
|
||||
}
|
||||
if (floatingMenuContentBounds.bottom + windowEdgeMargin >= workspaceBounds.bottom) {
|
||||
floatingMenuContent.style.bottom = `${windowEdgeMargin}px`;
|
||||
if (workspaceBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom";
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the rounded corner from the content where the tail perfectly meets the corner
|
||||
if (type === "Popover" && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
|
||||
// We use `.style` on a ref (instead of a `:style` Vue binding) because the binding causes the `updated()` hook to call the function we're in recursively forever
|
||||
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
|
||||
case "TopLeft":
|
||||
floatingMenuContent.style.borderTopLeftRadius = "0";
|
||||
break;
|
||||
case "TopRight":
|
||||
floatingMenuContent.style.borderTopRightRadius = "0";
|
||||
break;
|
||||
case "BottomLeft":
|
||||
floatingMenuContent.style.borderBottomLeftRadius = "0";
|
||||
break;
|
||||
case "BottomRight":
|
||||
floatingMenuContent.style.borderBottomRightRadius = "0";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function div(): HTMLDivElement {
|
||||
return self;
|
||||
}
|
||||
|
||||
// To be called by the parent component. Measures the actual width of the floating menu content element and returns it in a promise.
|
||||
export async function measureAndEmitNaturalWidth(): Promise<void> {
|
||||
// Wait for the changed content which fired the `updated()` Vue event to be put into the DOM
|
||||
await tick();
|
||||
|
||||
// Wait until all fonts have been loaded and rendered so measurements of content involving text are accurate
|
||||
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (document as any).fonts.ready;
|
||||
|
||||
// Make the component show itself with 0 min-width so it can be measured, and wait until the values have been updated to the DOM
|
||||
measuringOngoing = true;
|
||||
measuringOngoingGuard = true;
|
||||
await tick();
|
||||
|
||||
// Measure the width of the floating menu content element, if it's currently visible
|
||||
// The result will be `undefined` if the menu is invisible, perhaps because an ancestor component is hidden with a falsy `v-if` condition
|
||||
const naturalWidth: number | undefined = floatingMenuContent?.clientWidth;
|
||||
|
||||
// Turn off measuring mode for the component, which triggers another call to the `updated()` Vue event, so we can turn off the protection after that has happened
|
||||
measuringOngoing = false;
|
||||
await tick();
|
||||
measuringOngoingGuard = false;
|
||||
|
||||
// Emit the measured natural width to the parent
|
||||
if (naturalWidth !== undefined && naturalWidth >= 0) {
|
||||
dispatch("naturalWidth", naturalWidth);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerMoveHandler(e: PointerEvent) {
|
||||
// This element and the element being hovered over
|
||||
const target = e.target as HTMLElement | undefined;
|
||||
|
||||
// Get the spawner element (that which is clicked to spawn this floating menu)
|
||||
// Assumes the spawner is a sibling of this FloatingMenu component
|
||||
const ownSpawner: HTMLElement | undefined = self?.parentElement?.querySelector(":scope > [data-floating-menu-spawner]") || undefined;
|
||||
// Get the spawner element containing whatever element the user is hovering over now, if there is one
|
||||
const targetSpawner: HTMLElement | undefined = target?.closest("[data-floating-menu-spawner]") || undefined;
|
||||
|
||||
// HOVER TRANSFER
|
||||
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
|
||||
hoverTransfer(self, ownSpawner, targetSpawner);
|
||||
|
||||
// POINTER STRAY
|
||||
// Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner)
|
||||
const notHoveringOverOwnSpawner = ownSpawner !== targetSpawner;
|
||||
if (strayCloses && notHoveringOverOwnSpawner && isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) {
|
||||
// TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear
|
||||
// TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
||||
dispatch("open", false);
|
||||
}
|
||||
|
||||
// Clean up any messes from lost pointerup events
|
||||
const eventIncludesLmb = Boolean(e.buttons & 1);
|
||||
if (!open && !eventIncludesLmb) {
|
||||
pointerStillDown = false;
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function hoverTransfer(self: HTMLDivElement | undefined, ownSpawner: HTMLElement | undefined, targetSpawner: HTMLElement | undefined): void {
|
||||
// Algorithm pseudo-code to detect and transfer to hover-transferrable floating menu spawners
|
||||
// Accompanying diagram: <https://files.keavon.com/-/SpringgreenKnownXantus/capture.png>
|
||||
//
|
||||
// Check our own parent for descendant spawners
|
||||
// Filter out ourself and our children
|
||||
// Filter out all with a different distance than our own distance from the currently-being-checked parent
|
||||
// How many left?
|
||||
// None -> go up a level and repeat
|
||||
// Some -> is one of them the target?
|
||||
// Yes -> click it and terminate
|
||||
// No -> do nothing and terminate
|
||||
|
||||
// Helper function that gets used below
|
||||
const getDepthFromAncestor = (item: Element, ancestor: Element): number | undefined => {
|
||||
let depth = 1;
|
||||
|
||||
let parent = item.parentElement || undefined;
|
||||
while (parent) {
|
||||
if (parent === ancestor) return depth;
|
||||
|
||||
parent = parent.parentElement || undefined;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// A list of all the descendant spawners: the spawner for this floating menu plus any spawners belonging to widgets inside this floating menu
|
||||
const ownDescendantMenuSpawners = Array.from(self?.parentElement?.querySelectorAll("[data-floating-menu-spawner]") || []);
|
||||
|
||||
// Start with the parent of the spawner for this floating menu and keep widening the search for any other valid spawners that are hover-transferrable
|
||||
let currentAncestor = (targetSpawner && ownSpawner?.parentElement) || undefined;
|
||||
while (currentAncestor) {
|
||||
const ownSpawnerDepthFromCurrentAncestor = ownSpawner && getDepthFromAncestor(ownSpawner, currentAncestor);
|
||||
const currentAncestor2 = currentAncestor; // This duplicate variable avoids an ESLint warning
|
||||
|
||||
// Get the list of descendant spawners and filter out invalid possibilities for spawners that are hover-transferrable
|
||||
const listOfDescendantSpawners = Array.from(currentAncestor?.querySelectorAll("[data-floating-menu-spawner]") || []);
|
||||
const filteredListOfDescendantSpawners = listOfDescendantSpawners.filter((item: Element): boolean => {
|
||||
// Filter away ourself and our descendants
|
||||
const notOurself = !ownDescendantMenuSpawners.includes(item);
|
||||
// And filter away unequal depths from the current ancestor
|
||||
const notUnequalDepths = notOurself && getDepthFromAncestor(item, currentAncestor2) === ownSpawnerDepthFromCurrentAncestor;
|
||||
// And filter away elements that explicitly disable hover transfer
|
||||
return notUnequalDepths && !(item as HTMLElement).getAttribute?.("data-floating-menu-spawner")?.includes("no-hover-transfer");
|
||||
});
|
||||
|
||||
// If none were found, widen the search by a level and keep trying (or stop looping if the root was reached)
|
||||
if (filteredListOfDescendantSpawners.length === 0) {
|
||||
currentAncestor = currentAncestor?.parentElement || undefined;
|
||||
}
|
||||
// Stop after the first non-empty set was found
|
||||
else {
|
||||
const foundTarget = filteredListOfDescendantSpawners.find((item: Element): boolean => item === targetSpawner);
|
||||
// If the currently hovered spawner is one of the found valid hover-transferrable spawners, swap to it by clicking on it
|
||||
if (foundTarget) {
|
||||
dispatch("open", false);
|
||||
(foundTarget as HTMLElement).click();
|
||||
}
|
||||
|
||||
// In either case, we are done searching
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keyDownHandler(e: KeyboardEvent) {
|
||||
if (escapeCloses && e.key.toLowerCase() === "escape") {
|
||||
dispatch("open", false);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerDownHandler(e: PointerEvent) {
|
||||
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
|
||||
if (isPointerEventOutsideFloatingMenu(e)) {
|
||||
dispatch("open", false);
|
||||
|
||||
// Track if the left pointer button is now down so its later click event can be canceled
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (eventIsForLmb) pointerStillDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUpHandler(e: PointerEvent) {
|
||||
const eventIsForLmb = e.button === 0;
|
||||
if (pointerStillDown && eventIsForLmb) {
|
||||
// Clean up self
|
||||
pointerStillDown = false;
|
||||
window.removeEventListener("pointerup", pointerUpHandler);
|
||||
// Prevent the click event from firing, which would normally occur right after this pointerup event
|
||||
window.addEventListener("click", clickHandlerCapture, true);
|
||||
}
|
||||
}
|
||||
|
||||
function 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", clickHandlerCapture, true);
|
||||
}
|
||||
|
||||
function isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
|
||||
// Consider all child menus as well as the top-level one
|
||||
const allContainedFloatingMenus = [...self.querySelectorAll("[data-floating-menu-content]")];
|
||||
|
||||
return !allContainedFloatingMenus.find((element) => !isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
|
||||
}
|
||||
|
||||
function isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
|
||||
const floatingMenuBounds = element.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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`floating-menu ${direction.toLowerCase()} ${type.toLowerCase()} ${className} ${extraClasses}`.trim()}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
bind:this={self}
|
||||
{...$$restProps}
|
||||
>
|
||||
{#if displayTail}
|
||||
<div class="tail" bind:this={tail} />
|
||||
{/if}
|
||||
{#if displayContainer}
|
||||
<div class="floating-menu-container" bind:this={floatingMenuContainer}>
|
||||
<LayoutCol class="floating-menu-content" styles={{ "min-width": minWidthStyleValue }} {scrollableY} bind:this={floatingMenuContent} data-floating-menu-content>
|
||||
<slot />
|
||||
</LayoutCol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.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: rgba(var(--color-2-mildblack-rgb), 0.95);
|
||||
box-shadow: rgba(var(--color-0-black-rgb), 50%) 0 2px 4px;
|
||||
border-radius: var(--floating-menu-content-border-radius);
|
||||
color: var(--color-e-nearwhite);
|
||||
font-size: inherit;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
// 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;
|
||||
}
|
||||
|
||||
&.cursor .floating-menu-container .floating-menu-content {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> .floating-menu-container > .floating-menu-content {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.top,
|
||||
&.bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.top .tail {
|
||||
border-width: 8px 6px 0 6px;
|
||||
border-color: rgba(var(--color-2-mildblack-rgb), 0.95) transparent transparent transparent;
|
||||
margin-left: -6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&.bottom .tail {
|
||||
border-width: 0 6px 8px 6px;
|
||||
border-color: transparent transparent rgba(var(--color-2-mildblack-rgb), 0.95) transparent;
|
||||
margin-left: -6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.left .tail {
|
||||
border-width: 6px 0 6px 8px;
|
||||
border-color: transparent transparent transparent rgba(var(--color-2-mildblack-rgb), 0.95);
|
||||
margin-top: -6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.right .tail {
|
||||
border-width: 6px 8px 6px 0;
|
||||
border-color: transparent rgba(var(--color-2-mildblack-rgb), 0.95) 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>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let scrollableX: boolean = false;
|
||||
export let scrollableY: boolean = false;
|
||||
|
||||
let divElement: HTMLDivElement;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
export function div(): HTMLDivElement {
|
||||
return divElement;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`layout-col ${className} ${extraClasses}`.trim()}
|
||||
class:scrollable-x={scrollableX}
|
||||
class:scrollable-y={scrollableY}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
title={tooltip}
|
||||
bind:this={divElement}
|
||||
on:click
|
||||
on:dblclick
|
||||
on:pointerdown
|
||||
on:pointermove
|
||||
on:pointerup
|
||||
on:dragleave
|
||||
on:dragover
|
||||
on:dragstart
|
||||
on:dragend
|
||||
on:drop
|
||||
on:wheel
|
||||
on:scroll
|
||||
on:focus
|
||||
on:blur
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layout-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let scrollableX: boolean = false;
|
||||
export let scrollableY: boolean = false;
|
||||
|
||||
let divElement: HTMLDivElement;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
|
||||
export function div(): HTMLDivElement {
|
||||
return divElement;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`layout-row ${className} ${extraClasses}`.trim()}
|
||||
class:scrollable-x={scrollableX}
|
||||
class:scrollable-y={scrollableY}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
title={tooltip}
|
||||
bind:this={divElement}
|
||||
on:click
|
||||
on:dblclick
|
||||
on:pointerdown
|
||||
on:pointermove
|
||||
on:pointerup
|
||||
on:dragleave
|
||||
on:dragover
|
||||
on:dragstart
|
||||
on:dragend
|
||||
on:drop
|
||||
on:wheel
|
||||
on:scroll
|
||||
on:focus
|
||||
on:blur
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layout-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,607 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from "svelte";
|
||||
|
||||
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
|
||||
import { rasterizeSVGCanvas } from "@/utility-functions/rasterization";
|
||||
import {
|
||||
type MouseCursorIcon,
|
||||
type XY,
|
||||
DisplayEditableTextbox,
|
||||
DisplayRemoveEditableTextbox,
|
||||
TriggerTextCommit,
|
||||
TriggerViewportResize,
|
||||
UpdateDocumentArtboards,
|
||||
UpdateDocumentArtwork,
|
||||
UpdateDocumentOverlays,
|
||||
UpdateDocumentRulers,
|
||||
UpdateDocumentScrollbars,
|
||||
UpdateEyedropperSamplingState,
|
||||
UpdateMouseCursor,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@/components/floating-menus/EyedropperPreview.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.svelte";
|
||||
import PersistentScrollbar from "@/components/widgets/metrics/PersistentScrollbar.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { type DocumentState } from "@/state-providers/document";
|
||||
|
||||
let self: LayoutCol;
|
||||
let rulerHorizontal: CanvasRuler;
|
||||
let rulerVertical: CanvasRuler;
|
||||
let canvasDiv: HTMLDivElement;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const document = getContext<DocumentState>("document");
|
||||
|
||||
// Interactive text editing
|
||||
let textInput: undefined | HTMLDivElement = undefined;
|
||||
|
||||
// CSS properties
|
||||
let canvasSvgWidth: number | undefined = undefined;
|
||||
let canvasSvgHeight: number | undefined = undefined;
|
||||
let canvasCursor = "default";
|
||||
|
||||
// Scrollbars
|
||||
let scrollbarPos: XY = { x: 0.5, y: 0.5 };
|
||||
let scrollbarSize: XY = { x: 0.5, y: 0.5 };
|
||||
let scrollbarMultiplier: XY = { x: 0, y: 0 };
|
||||
|
||||
// Rulers
|
||||
let rulerOrigin: XY = { x: 0, y: 0 };
|
||||
let rulerSpacing: number = 100;
|
||||
let rulerInterval: number = 100;
|
||||
|
||||
// Rendered SVG viewport data
|
||||
let artworkSvg: string = "";
|
||||
let artboardSvg: string = "";
|
||||
let overlaysSvg: string = "";
|
||||
|
||||
// Rasterized SVG viewport data, or none if it's not up-to-date
|
||||
let rasterizedCanvas: HTMLCanvasElement | undefined = undefined;
|
||||
let rasterizedContext: CanvasRenderingContext2D | undefined = undefined;
|
||||
|
||||
// Cursor position for cursor floating menus like the Eyedropper tool zoom
|
||||
let cursorLeft = 0;
|
||||
let cursorTop = 0;
|
||||
let cursorEyedropper = false;
|
||||
let cursorEyedropperPreviewImageData: ImageData | undefined = undefined;
|
||||
let cursorEyedropperPreviewColorChoice = "";
|
||||
let cursorEyedropperPreviewColorPrimary = "";
|
||||
let cursorEyedropperPreviewColorSecondary = "";
|
||||
|
||||
$: canvasWidthCSS = canvasDimensionCSS(canvasSvgWidth);
|
||||
$: canvasHeightCSS = canvasDimensionCSS(canvasSvgHeight);
|
||||
|
||||
function pasteFile(e: DragEvent) {
|
||||
const { dataTransfer } = e;
|
||||
if (!dataTransfer) return;
|
||||
e.preventDefault();
|
||||
|
||||
Array.from(dataTransfer.items).forEach(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (file?.type.startsWith("image")) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const u8Array = new Uint8Array(buffer);
|
||||
|
||||
editor.instance.pasteImage(file.type, u8Array, e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function translateCanvasX(newValue: number) {
|
||||
const delta = newValue - scrollbarPos.x;
|
||||
scrollbarPos.x = newValue;
|
||||
editor.instance.translateCanvas(-delta * scrollbarMultiplier.x, 0);
|
||||
}
|
||||
|
||||
function translateCanvasY(newValue: number) {
|
||||
const delta = newValue - scrollbarPos.y;
|
||||
scrollbarPos.y = newValue;
|
||||
editor.instance.translateCanvas(0, -delta * scrollbarMultiplier.y);
|
||||
}
|
||||
|
||||
function pageX(delta: number) {
|
||||
const move = delta < 0 ? 1 : -1;
|
||||
editor.instance.translateCanvasByFraction(move, 0);
|
||||
}
|
||||
|
||||
function pageY(delta: number) {
|
||||
const move = delta < 0 ? 1 : -1;
|
||||
editor.instance.translateCanvasByFraction(0, move);
|
||||
}
|
||||
|
||||
function canvasPointerDown(e: PointerEvent) {
|
||||
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
|
||||
|
||||
if (!onEditbox) canvasDiv?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
// Update rendered SVGs
|
||||
export async function updateDocumentArtwork(svg: string) {
|
||||
artworkSvg = svg;
|
||||
rasterizedCanvas = undefined;
|
||||
|
||||
await tick();
|
||||
|
||||
if (textInput) {
|
||||
const foreignObject = canvasDiv.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
|
||||
if (foreignObject.children.length > 0) return;
|
||||
|
||||
const addedInput = foreignObject.appendChild(textInput);
|
||||
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: addedInput }));
|
||||
|
||||
await tick();
|
||||
|
||||
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
|
||||
|
||||
const range = window.document.createRange();
|
||||
range.selectNodeContents(addedInput);
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
addedInput.focus();
|
||||
addedInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDocumentOverlays(svg: string) {
|
||||
overlaysSvg = svg;
|
||||
}
|
||||
|
||||
export function updateDocumentArtboards(svg: string) {
|
||||
artboardSvg = svg;
|
||||
rasterizedCanvas = undefined;
|
||||
}
|
||||
|
||||
export async function updateEyedropperSamplingState(mousePosition: XY | undefined, colorPrimary: string, colorSecondary: string): Promise<[number, number, number] | undefined> {
|
||||
if (mousePosition === undefined) {
|
||||
cursorEyedropper = false;
|
||||
return undefined;
|
||||
}
|
||||
cursorEyedropper = true;
|
||||
|
||||
if (canvasSvgWidth === undefined || canvasSvgHeight === undefined) return undefined;
|
||||
|
||||
cursorLeft = mousePosition.x;
|
||||
cursorTop = mousePosition.y;
|
||||
|
||||
// This works nearly perfectly, but sometimes at odd DPI scale factors like 1.25, the anti-aliasing color can yield slightly incorrect colors (potential room for future improvement)
|
||||
const dpiFactor = window.devicePixelRatio;
|
||||
const [width, height] = [canvasSvgWidth, canvasSvgHeight];
|
||||
|
||||
const outsideArtboardsColor = getComputedStyle(window.document.documentElement).getPropertyValue("--color-2-mildblack");
|
||||
const outsideArtboards = `<rect x="0" y="0" width="100%" height="100%" fill="${outsideArtboardsColor}" />`;
|
||||
const artboards = artboardSvg;
|
||||
const artwork = artworkSvg;
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${outsideArtboards}${artboards}${artwork}</svg>
|
||||
`.trim();
|
||||
|
||||
if (!rasterizedCanvas) {
|
||||
rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor, "image/png");
|
||||
rasterizedContext = rasterizedCanvas.getContext("2d") || undefined;
|
||||
}
|
||||
if (!rasterizedContext) return undefined;
|
||||
|
||||
const rgbToHex = (r: number, g: number, b: number): string => `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
|
||||
|
||||
const pixel = rasterizedContext.getImageData(mousePosition.x * dpiFactor, mousePosition.y * dpiFactor, 1, 1).data;
|
||||
const hex = rgbToHex(pixel[0], pixel[1], pixel[2]);
|
||||
const rgb: [number, number, number] = [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255];
|
||||
|
||||
cursorEyedropperPreviewColorChoice = hex;
|
||||
cursorEyedropperPreviewColorPrimary = colorPrimary;
|
||||
cursorEyedropperPreviewColorSecondary = colorSecondary;
|
||||
|
||||
const previewRegion = rasterizedContext.getImageData(
|
||||
mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
|
||||
mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
|
||||
ZOOM_WINDOW_DIMENSIONS,
|
||||
ZOOM_WINDOW_DIMENSIONS
|
||||
);
|
||||
cursorEyedropperPreviewImageData = previewRegion;
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
// Update scrollbars and rulers
|
||||
export function updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
|
||||
scrollbarPos = position;
|
||||
scrollbarSize = size;
|
||||
scrollbarMultiplier = multiplier;
|
||||
}
|
||||
|
||||
export function updateDocumentRulers(origin: XY, spacing: number, interval: number) {
|
||||
rulerOrigin = origin;
|
||||
rulerSpacing = spacing;
|
||||
rulerInterval = interval;
|
||||
}
|
||||
|
||||
// Update mouse cursor icon
|
||||
export function updateMouseCursor(cursor: MouseCursorIcon) {
|
||||
let cursorString: string = cursor;
|
||||
|
||||
// This isn't very clean but it's good enough for now until we need more icons, then we can build something more robust (consider blob URLs)
|
||||
if (cursor === "custom-rotate") {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<path transform="translate(2 2)" fill="black" stroke="black" stroke-width="2px" d="
|
||||
M8,15.2C4,15.2,0.8,12,0.8,8C0.8,4,4,0.8,8,0.8c2,0,3.9,0.8,5.3,2.3l-1,1C11.2,2.9,9.6,2.2,8,2.2C4.8,2.2,2.2,4.8,2.2,8s2.6,5.8,5.8,5.8s5.8-2.6,5.8-5.8h1.4C15.2,12,12,15.2,8,15.2z
|
||||
" />
|
||||
<polygon transform="translate(2 2)" fill="black" stroke="black" stroke-width="2px" points="12.6,0 15.5,5 9.7,5" />
|
||||
<path transform="translate(2 2)" fill="white" d="
|
||||
M8,15.2C4,15.2,0.8,12,0.8,8C0.8,4,4,0.8,8,0.8c2,0,3.9,0.8,5.3,2.3l-1,1C11.2,2.9,9.6,2.2,8,2.2C4.8,2.2,2.2,4.8,2.2,8s2.6,5.8,5.8,5.8s5.8-2.6,5.8-5.8h1.4C15.2,12,12,15.2,8,15.2z
|
||||
" />
|
||||
<polygon transform="translate(2 2)" fill="white" points="12.6,0 15.5,5 9.7,5" />
|
||||
</svg>
|
||||
`
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("");
|
||||
|
||||
cursorString = `url('data:image/svg+xml;utf8,${svg}') 8 8, alias`;
|
||||
}
|
||||
|
||||
canvasCursor = cursorString;
|
||||
}
|
||||
|
||||
// Text entry
|
||||
export function triggerTextCommit() {
|
||||
if (!textInput) return;
|
||||
const textCleaned = textInputCleanup(textInput.innerText);
|
||||
editor.instance.onChangeText(textCleaned);
|
||||
}
|
||||
|
||||
export function displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
|
||||
textInput = window.document.createElement("div") as HTMLDivElement;
|
||||
|
||||
if (displayEditableTextbox.text === "") textInput.textContent = "";
|
||||
else textInput.textContent = `${displayEditableTextbox.text}\n`;
|
||||
|
||||
textInput.contentEditable = "true";
|
||||
textInput.style.width = displayEditableTextbox.lineWidth ? `${displayEditableTextbox.lineWidth}px` : "max-content";
|
||||
textInput.style.height = "auto";
|
||||
textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
|
||||
textInput.style.color = displayEditableTextbox.color.toHexOptionalAlpha() || "transparent";
|
||||
|
||||
textInput.oninput = (): void => {
|
||||
if (!textInput) return;
|
||||
editor.instance.updateBounds(textInputCleanup(textInput.innerText));
|
||||
};
|
||||
}
|
||||
|
||||
export function displayRemoveEditableTextbox() {
|
||||
textInput = undefined;
|
||||
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: undefined }));
|
||||
}
|
||||
|
||||
// Resize elements to render the new viewport size
|
||||
export function viewportResize() {
|
||||
// Resize the canvas
|
||||
canvasSvgWidth = Math.ceil(parseFloat(getComputedStyle(canvasDiv).width));
|
||||
canvasSvgHeight = Math.ceil(parseFloat(getComputedStyle(canvasDiv).height));
|
||||
|
||||
// Resize the rulers
|
||||
rulerHorizontal?.resize();
|
||||
rulerVertical?.resize();
|
||||
}
|
||||
|
||||
function canvasDimensionCSS(dimension: number | undefined): string {
|
||||
// Temporary placeholder until the first actual value is populated
|
||||
// This at least gets close to the correct value but an actual number is required to prevent CSS from causing non-integer sizing making the SVG render with anti-aliasing
|
||||
if (dimension === undefined) return "100%";
|
||||
|
||||
// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
|
||||
return `${dimension % 2 === 1 ? dimension + 1 : dimension}px`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Update rendered SVGs
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => {
|
||||
await tick();
|
||||
|
||||
updateDocumentArtwork(data.svg);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentOverlays, async (data) => {
|
||||
await tick();
|
||||
|
||||
updateDocumentOverlays(data.svg);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtboards, async (data) => {
|
||||
await tick();
|
||||
|
||||
updateDocumentArtboards(data.svg);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
|
||||
const rgb = await updateEyedropperSamplingState(mousePosition, primaryColor, secondaryColor);
|
||||
|
||||
if (setColorChoice && rgb) {
|
||||
if (setColorChoice === "Primary") editor.instance.updatePrimaryColor(...rgb, 1);
|
||||
if (setColorChoice === "Secondary") editor.instance.updateSecondaryColor(...rgb, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Update scrollbars and rulers
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { position, size, multiplier } = data;
|
||||
updateDocumentScrollbars(position, size, multiplier);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { origin, spacing, interval } = data;
|
||||
updateDocumentRulers(origin, spacing, interval);
|
||||
});
|
||||
|
||||
// Update mouse cursor icon
|
||||
editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, async (data) => {
|
||||
await tick();
|
||||
|
||||
const { cursor } = data;
|
||||
updateMouseCursor(cursor);
|
||||
});
|
||||
|
||||
// Text entry
|
||||
editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => {
|
||||
await tick();
|
||||
|
||||
triggerTextCommit();
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (data) => {
|
||||
await tick();
|
||||
|
||||
displayEditableTextbox(data);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => {
|
||||
await tick();
|
||||
|
||||
displayRemoveEditableTextbox();
|
||||
});
|
||||
|
||||
// Resize elements to render the new viewport size
|
||||
editor.subscriptions.subscribeJsMessage(TriggerViewportResize, async () => {
|
||||
await tick();
|
||||
|
||||
viewportResize();
|
||||
});
|
||||
|
||||
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol class="document" bind:this={self}>
|
||||
<LayoutRow class="options-bar" scrollableX={true}>
|
||||
<WidgetLayout layout={$document.documentModeLayout} />
|
||||
<WidgetLayout layout={$document.toolOptionsLayout} />
|
||||
|
||||
<LayoutRow class="spacer" />
|
||||
|
||||
<WidgetLayout layout={$document.documentBarLayout} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="shelf-and-viewport">
|
||||
<LayoutCol class="shelf">
|
||||
<LayoutCol class="tools" scrollableY={true}>
|
||||
<WidgetLayout layout={$document.toolShelfLayout} />
|
||||
</LayoutCol>
|
||||
|
||||
<LayoutCol class="spacer" />
|
||||
|
||||
<LayoutCol class="working-colors">
|
||||
<WidgetLayout layout={$document.workingColorsLayout} />
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="viewport">
|
||||
<LayoutRow class="bar-area top-ruler">
|
||||
<CanvasRuler origin={rulerOrigin.x} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Horizontal" bind:this={rulerHorizontal} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="canvas-area">
|
||||
<LayoutCol class="bar-area">
|
||||
<CanvasRuler origin={rulerOrigin.y} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Vertical" bind:this={rulerVertical} />
|
||||
</LayoutCol>
|
||||
<LayoutCol class="canvas-area" styles={{ cursor: canvasCursor }}>
|
||||
{#if cursorEyedropper}
|
||||
<EyedropperPreview
|
||||
colorChoice={cursorEyedropperPreviewColorChoice}
|
||||
primaryColor={cursorEyedropperPreviewColorPrimary}
|
||||
secondaryColor={cursorEyedropperPreviewColorSecondary}
|
||||
imageData={cursorEyedropperPreviewImageData}
|
||||
x={cursorLeft}
|
||||
y={cursorTop}
|
||||
/>
|
||||
{/if}
|
||||
<div class="canvas" on:pointerdown={(e) => canvasPointerDown(e)} on:dragover={(e) => e.preventDefault()} on:drop={(e) => pasteFile(e)} bind:this={canvasDiv} data-canvas>
|
||||
<svg class="artboards" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html artboardSvg}
|
||||
</svg>
|
||||
<svg class="artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html artworkSvg}
|
||||
</svg>
|
||||
<svg class="overlays" style:width={canvasWidthCSS} style:height={canvasHeightCSS}>
|
||||
{@html overlaysSvg}
|
||||
</svg>
|
||||
</div>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="bar-area right-scrollbar">
|
||||
<PersistentScrollbar
|
||||
direction="Vertical"
|
||||
handleLength={scrollbarSize.y}
|
||||
handlePosition={scrollbarPos.y}
|
||||
on:handlePosition={({ detail }) => translateCanvasY(detail)}
|
||||
on:pressTrack={({ detail }) => pageY(detail)}
|
||||
/>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
<LayoutRow class="bar-area bottom-scrollbar">
|
||||
<PersistentScrollbar
|
||||
direction="Horizontal"
|
||||
handleLength={scrollbarSize.x}
|
||||
handlePosition={scrollbarPos.x}
|
||||
on:handlePosition={({ detail }) => translateCanvasX(detail)}
|
||||
on:pressTrack={({ detail }) => pageX(detail)}
|
||||
/>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.document {
|
||||
height: 100%;
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
|
||||
.spacer {
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.shelf-and-viewport {
|
||||
.shelf {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.tools {
|
||||
flex: 0 1 auto;
|
||||
|
||||
.icon-button[title^="Coming Soon"] {
|
||||
opacity: 0.25;
|
||||
transition: opacity 0.25s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button:not(.active) {
|
||||
.color-solid {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
.color-general {
|
||||
fill: var(--color-data-general);
|
||||
}
|
||||
|
||||
.color-vector {
|
||||
fill: var(--color-data-vector);
|
||||
}
|
||||
|
||||
.color-raster {
|
||||
fill: var(--color-data-raster);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 0 auto;
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
.working-colors {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.widget-row {
|
||||
min-height: 0;
|
||||
|
||||
.swatch-pair {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
--widget-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
flex: 1 1 100%;
|
||||
|
||||
.canvas-area {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-area {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.top-ruler .canvas-ruler {
|
||||
padding-left: 16px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.right-scrollbar .persistent-scrollbar {
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
.bottom-scrollbar .persistent-scrollbar {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
background: var(--color-2-mildblack);
|
||||
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 {
|
||||
position: absolute;
|
||||
// Fallback values if JS hasn't set these to integers yet
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// Allows dev tools to select the artwork without being blocked by the SVG containers
|
||||
pointer-events: none;
|
||||
|
||||
// Prevent inheritance from reaching the child elements
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
foreignObject {
|
||||
width: 10000px;
|
||||
height: 10000px;
|
||||
overflow: visible;
|
||||
|
||||
div {
|
||||
cursor: text;
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
white-space: pre-wrap;
|
||||
display: inline-block;
|
||||
// Workaround to force Chrome to display the flashing text entry cursor when text is empty
|
||||
padding-left: 1px;
|
||||
margin-left: -1px;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none; // Ok for contenteditable element
|
||||
margin: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,577 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from "svelte";
|
||||
|
||||
import { beginDraggingElement } from "@/io-managers/drag";
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import {
|
||||
type LayerType,
|
||||
type LayerTypeData,
|
||||
type LayerPanelEntry,
|
||||
defaultWidgetLayout,
|
||||
patchWidgetLayout,
|
||||
UpdateDocumentLayerDetails,
|
||||
UpdateDocumentLayerTreeStructureJs,
|
||||
UpdateLayerTreeOptionsLayout,
|
||||
layerTypeData,
|
||||
} from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
type LayerListingInfo = {
|
||||
folderIndex: number;
|
||||
bottomLayer: boolean;
|
||||
editingName: boolean;
|
||||
entry: LayerPanelEntry;
|
||||
};
|
||||
|
||||
let list: LayoutCol;
|
||||
|
||||
const RANGE_TO_INSERT_WITHIN_BOTTOM_FOLDER_NOT_ROOT = 20;
|
||||
const LAYER_INDENT = 16;
|
||||
const INSERT_MARK_MARGIN_LEFT = 4 + 32 + LAYER_INDENT;
|
||||
const INSERT_MARK_OFFSET = 2;
|
||||
|
||||
type DraggingData = {
|
||||
select?: () => void;
|
||||
insertFolder: BigUint64Array;
|
||||
insertIndex: number;
|
||||
highlightFolder: boolean;
|
||||
markerHeight: number;
|
||||
};
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
// Layer data
|
||||
let layerCache: Map<string, LayerPanelEntry> = new Map(); // TODO: replace with BigUint64Array as index
|
||||
let layers: LayerListingInfo[] = [];
|
||||
|
||||
// Interactive dragging
|
||||
let draggable = true;
|
||||
let draggingData: undefined | DraggingData = undefined;
|
||||
let fakeHighlight: undefined | BigUint64Array[] = undefined;
|
||||
let dragInPanel = false;
|
||||
|
||||
// Layouts
|
||||
let layerTreeOptionsLayout = defaultWidgetLayout();
|
||||
|
||||
onMount(() => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructureJs, (updateDocumentLayerTreeStructure) => {
|
||||
rebuildLayerTree(updateDocumentLayerTreeStructure);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateLayerTreeOptionsLayout, (updateLayerTreeOptionsLayout) => {
|
||||
patchWidgetLayout(layerTreeOptionsLayout, updateLayerTreeOptionsLayout);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => {
|
||||
const targetPath = updateDocumentLayerDetails.data.path;
|
||||
const targetLayer = updateDocumentLayerDetails.data;
|
||||
|
||||
const layer = layerCache.get(targetPath.toString());
|
||||
if (layer) {
|
||||
Object.assign(layer, targetLayer);
|
||||
} else {
|
||||
layerCache.set(targetPath.toString(), targetLayer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function layerIndent(layer: LayerPanelEntry): string {
|
||||
return `${layer.path.length * LAYER_INDENT}px`;
|
||||
}
|
||||
|
||||
function markIndent(path: BigUint64Array): string {
|
||||
return `${INSERT_MARK_MARGIN_LEFT + path.length * LAYER_INDENT}px`;
|
||||
}
|
||||
|
||||
function markTopOffset(height: number): string {
|
||||
return `${height}px`;
|
||||
}
|
||||
|
||||
function toggleLayerVisibility(path: BigUint64Array) {
|
||||
editor.instance.toggleLayerVisibility(path);
|
||||
}
|
||||
|
||||
function handleExpandArrowClick(path: BigUint64Array) {
|
||||
editor.instance.toggleLayerExpansion(path);
|
||||
}
|
||||
|
||||
async function onEditLayerName(listing: LayerListingInfo) {
|
||||
if (listing.editingName) return;
|
||||
|
||||
listing.editingName = true;
|
||||
draggable = false;
|
||||
|
||||
await tick();
|
||||
|
||||
const textInput: HTMLInputElement | undefined = list?.querySelector("[data-text-input]:not([disabled])") || undefined;
|
||||
textInput?.select();
|
||||
}
|
||||
|
||||
function onEditLayerNameChange(listing: LayerListingInfo, e: Event) {
|
||||
// Eliminate duplicate events
|
||||
if (!listing.editingName) return;
|
||||
|
||||
draggable = true;
|
||||
|
||||
const name = (e.target as HTMLInputElement | undefined)?.value;
|
||||
listing.editingName = false;
|
||||
if (name) editor.instance.setLayerName(listing.entry.path, name);
|
||||
}
|
||||
|
||||
async function onEditLayerNameDeselect(listing: LayerListingInfo) {
|
||||
draggable = true;
|
||||
|
||||
listing.editingName = false;
|
||||
|
||||
await tick();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
// TODO: Svelte: test this works
|
||||
function selectLayerWithModifiers(e: MouseEvent, listing: LayerListingInfo) {
|
||||
const ctrl = e.ctrlKey;
|
||||
const meta = e.metaKey;
|
||||
const shift = e.shiftKey;
|
||||
const alt = e.altKey;
|
||||
|
||||
if (!ctrl && !meta && !shift && !alt) selectLayer(false, false, false, listing, e);
|
||||
else if (!ctrl && !meta && shift && !alt) selectLayer(false, false, true, listing, e);
|
||||
else if (ctrl && !meta && !shift && !alt) selectLayer(true, false, false, listing, e);
|
||||
else if (ctrl && !meta && shift && !alt) selectLayer(true, false, true, listing, e);
|
||||
else if (!ctrl && meta && !shift && !alt) selectLayer(false, true, false, listing, e);
|
||||
else if (!ctrl && meta && shift && !alt) selectLayer(false, true, true, listing, e);
|
||||
else if ((ctrl && meta) || alt) e.stopPropagation();
|
||||
}
|
||||
|
||||
async function selectLayer(ctrl: boolean, cmd: boolean, shift: boolean, listing: LayerListingInfo, event: Event) {
|
||||
if (listing.editingName) return;
|
||||
|
||||
const ctrlOrCmd = platformIsMac() ? cmd : ctrl;
|
||||
// Pressing the Ctrl key on a Mac, or the Cmd key on another platform, is a violation of the `.exact` qualifier so we filter it out here
|
||||
const opposite = platformIsMac() ? ctrl : cmd;
|
||||
|
||||
if (!opposite) editor.instance.selectLayer(listing.entry.path, ctrlOrCmd, shift);
|
||||
|
||||
// We always want to stop propagation so the click event doesn't pass through the layer and cause a deselection by clicking the layer panel background
|
||||
// This is also why we cover the remaining cases not considered by the `.exact` qualifier, in the last two bindings on the layer element, with a `stopPropagation()` call
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
async function deselectAllLayers() {
|
||||
editor.instance.deselectAllLayers();
|
||||
}
|
||||
|
||||
function calculateDragIndex(tree: LayoutCol, clientY: number, select?: () => void): DraggingData {
|
||||
const treeChildren = tree.div().children;
|
||||
const treeOffset = tree.getBoundingClientRect().top;
|
||||
|
||||
// Closest distance to the middle of the row along the Y axis
|
||||
let closest = Infinity;
|
||||
|
||||
// Folder to insert into
|
||||
let insertFolder = new BigUint64Array();
|
||||
|
||||
// Insert index
|
||||
let insertIndex = -1;
|
||||
|
||||
// Whether you are inserting into a folder and should show the folder outline
|
||||
let highlightFolder = false;
|
||||
|
||||
let markerHeight = 0;
|
||||
let previousHeight = undefined as undefined | number;
|
||||
|
||||
Array.from(treeChildren).forEach((treeChild, index) => {
|
||||
const layerComponents = treeChild.getElementsByClassName("layer");
|
||||
if (layerComponents.length !== 1) return;
|
||||
const child = layerComponents[0];
|
||||
|
||||
const indexAttribute = child.getAttribute("data-index");
|
||||
if (!indexAttribute) return;
|
||||
const { folderIndex, entry: layer } = layers[parseInt(indexAttribute, 10)];
|
||||
|
||||
const rect = child.getBoundingClientRect();
|
||||
const position = rect.top + rect.height / 2;
|
||||
const distance = position - clientY;
|
||||
|
||||
// Inserting above current row
|
||||
if (distance > 0 && distance < closest) {
|
||||
insertFolder = layer.path.slice(0, layer.path.length - 1);
|
||||
insertIndex = folderIndex;
|
||||
highlightFolder = false;
|
||||
closest = distance;
|
||||
markerHeight = previousHeight || treeOffset + INSERT_MARK_OFFSET;
|
||||
}
|
||||
// Inserting below current row
|
||||
else if (distance > -closest && distance > -RANGE_TO_INSERT_WITHIN_BOTTOM_FOLDER_NOT_ROOT && distance < 0) {
|
||||
insertFolder = layer.layerType === "Folder" ? layer.path : layer.path.slice(0, layer.path.length - 1);
|
||||
insertIndex = layer.layerType === "Folder" ? 0 : folderIndex + 1;
|
||||
highlightFolder = layer.layerType === "Folder";
|
||||
closest = -distance;
|
||||
markerHeight = index === treeChildren.length - 1 ? rect.bottom - INSERT_MARK_OFFSET : rect.bottom;
|
||||
}
|
||||
// Inserting with no nesting at the end of the panel
|
||||
else if (closest === Infinity) {
|
||||
if (layer.path.length === 1) insertIndex = folderIndex + 1;
|
||||
|
||||
markerHeight = rect.bottom - INSERT_MARK_OFFSET;
|
||||
}
|
||||
previousHeight = rect.bottom;
|
||||
});
|
||||
|
||||
markerHeight -= treeOffset;
|
||||
|
||||
return {
|
||||
select,
|
||||
insertFolder,
|
||||
insertIndex,
|
||||
highlightFolder,
|
||||
markerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
async function dragStart(event: DragEvent, listing: LayerListingInfo) {
|
||||
const layer = listing.entry;
|
||||
dragInPanel = true;
|
||||
if (!layer.layerMetadata.selected) {
|
||||
fakeHighlight = [layer.path];
|
||||
}
|
||||
const select = (): void => {
|
||||
if (!layer.layerMetadata.selected) selectLayer(false, false, false, listing, event);
|
||||
};
|
||||
|
||||
const target = (event.target || undefined) as HTMLElement | undefined;
|
||||
const draggingELement = (target?.closest("[data-layer]") || undefined) as HTMLElement | undefined;
|
||||
if (draggingELement) beginDraggingElement(draggingELement);
|
||||
|
||||
// Set style of cursor for drag
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
if (list) draggingData = calculateDragIndex(list, event.clientY, select);
|
||||
}
|
||||
|
||||
function updateInsertLine(event: DragEvent) {
|
||||
// Stop the drag from being shown as cancelled
|
||||
event.preventDefault();
|
||||
dragInPanel = true;
|
||||
|
||||
if (list) draggingData = calculateDragIndex(list, event.clientY, draggingData?.select);
|
||||
}
|
||||
|
||||
async function drop() {
|
||||
if (draggingData && dragInPanel) {
|
||||
const { select, insertFolder, insertIndex } = draggingData;
|
||||
|
||||
select?.();
|
||||
editor.instance.moveLayerInTree(insertFolder, insertIndex);
|
||||
}
|
||||
draggingData = undefined;
|
||||
fakeHighlight = undefined;
|
||||
dragInPanel = false;
|
||||
}
|
||||
|
||||
function rebuildLayerTree(updateDocumentLayerTreeStructure: UpdateDocumentLayerTreeStructureJs) {
|
||||
const layerWithNameBeingEdited = layers.find((layer: LayerListingInfo) => layer.editingName);
|
||||
const layerPathWithNameBeingEdited = layerWithNameBeingEdited?.entry.path;
|
||||
const layerIdWithNameBeingEdited = layerPathWithNameBeingEdited?.slice(-1)[0];
|
||||
const path = [] as bigint[];
|
||||
layers = [] as LayerListingInfo[];
|
||||
|
||||
const recurse = (folder: UpdateDocumentLayerTreeStructureJs, layers: LayerListingInfo[], cache: Map<string, LayerPanelEntry>): void => {
|
||||
folder.children.forEach((item, index) => {
|
||||
// TODO: fix toString
|
||||
const layerId = BigInt(item.layerId.toString());
|
||||
path.push(layerId);
|
||||
|
||||
const mapping = cache.get(path.toString());
|
||||
if (mapping) {
|
||||
layers.push({
|
||||
folderIndex: index,
|
||||
bottomLayer: index === folder.children.length - 1,
|
||||
entry: mapping,
|
||||
editingName: layerIdWithNameBeingEdited === layerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Call self recursively if there are any children
|
||||
if (item.children.length >= 1) recurse(item, layers, cache);
|
||||
|
||||
path.pop();
|
||||
});
|
||||
};
|
||||
|
||||
recurse(updateDocumentLayerTreeStructure, layers, layerCache);
|
||||
}
|
||||
|
||||
function getLayerTypeData(layerType: LayerType): LayerTypeData {
|
||||
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="layer-tree" on:dragleave={() => (dragInPanel = false)}>
|
||||
<LayoutRow class="options-bar" scrollableX={true}>
|
||||
<WidgetLayout layout={layerTreeOptionsLayout} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="layer-tree-rows" scrollableY={true}>
|
||||
<LayoutCol class="list" bind:this={list} on:click={() => deselectAllLayers()} on:dragover={(e) => draggable && updateInsertLine(e)} on:dragend={() => draggable && drop()}>
|
||||
{#each layers as listing, index (String(listing.entry.path.slice(-1)))}
|
||||
<LayoutRow
|
||||
class="layer-row"
|
||||
classes={{
|
||||
"insert-folder": (draggingData?.highlightFolder || false) && draggingData?.insertFolder === listing.entry.path,
|
||||
}}
|
||||
>
|
||||
<LayoutRow class="visibility">
|
||||
<IconButton
|
||||
action={(e) => (toggleLayerVisibility(listing.entry.path), e?.stopPropagation())}
|
||||
size={24}
|
||||
icon={listing.entry.visible ? "EyeVisible" : "EyeHidden"}
|
||||
tooltip={listing.entry.visible ? "Visible" : "Hidden"}
|
||||
/>
|
||||
</LayoutRow>
|
||||
|
||||
<div class="indent" style:margin-left={layerIndent(listing.entry)} />
|
||||
|
||||
{#if listing.entry.layerType === "Folder"}
|
||||
<button class="expand-arrow" class:expanded={listing.entry.layerMetadata.expanded} on:click|stopPropagation={() => handleExpandArrowClick(listing.entry.path)} tabindex="0" />
|
||||
{/if}
|
||||
<LayoutRow
|
||||
class="layer"
|
||||
classes={{
|
||||
selected: fakeHighlight ? fakeHighlight.includes(listing.entry.path) : listing.entry.layerMetadata.selected,
|
||||
}}
|
||||
data-layer={String(listing.entry.path)}
|
||||
data-index={index}
|
||||
tooltip={listing.entry.tooltip}
|
||||
{draggable}
|
||||
on:dragstart={(e) => draggable && dragStart(e, listing)}
|
||||
on:click={(e) => selectLayerWithModifiers(e, listing)}
|
||||
>
|
||||
<LayoutRow class="layer-type-icon">
|
||||
<IconLabel icon={getLayerTypeData(listing.entry.layerType).icon} tooltip={getLayerTypeData(listing.entry.layerType).name} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="layer-name" on:dblclick={() => onEditLayerName(listing)}>
|
||||
<input
|
||||
data-text-input
|
||||
type="text"
|
||||
value={listing.entry.name}
|
||||
placeholder={getLayerTypeData(listing.entry.layerType).name}
|
||||
disabled={!listing.editingName}
|
||||
on:blur={() => onEditLayerNameDeselect(listing)}
|
||||
on:keydown={(e) => e.key === "Escape" && onEditLayerNameDeselect(listing)}
|
||||
on:keydown={(e) => e.key === "Enter" && onEditLayerNameChange(listing, e)}
|
||||
on:change={(e) => onEditLayerNameChange(listing, e)}
|
||||
/>
|
||||
</LayoutRow>
|
||||
<div class="thumbnail">
|
||||
{@html listing.entry.thumbnail}
|
||||
</div>
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{#if draggingData && !draggingData.highlightFolder && dragInPanel}
|
||||
<div class="insert-mark" style:left={markIndent(draggingData.insertFolder)} style:top={markTopOffset(draggingData.markerHeight)} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layer-tree {
|
||||
// Options bar
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
align-items: center;
|
||||
|
||||
.widget-layout {
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
// Blend mode selector
|
||||
.dropdown-input {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
// Blend mode selector and opacity slider
|
||||
.dropdown-input,
|
||||
.number-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer tree
|
||||
.layer-tree-rows {
|
||||
margin-top: 4px;
|
||||
// Crop away the 1px border below the bottom layer entry when it uses the full space of this panel
|
||||
margin-bottom: -1px;
|
||||
position: relative;
|
||||
|
||||
.layer-row {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
height: 32px;
|
||||
margin: 0 4px;
|
||||
border-bottom: 1px solid var(--color-4-dimgray);
|
||||
|
||||
.visibility {
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
|
||||
.icon-button {
|
||||
height: 100%;
|
||||
width: calc(24px + 2 * 4px);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: -16px;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
position: relative;
|
||||
background: none;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 3px 0 3px 6px;
|
||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded::after {
|
||||
border-width: 6px 3px 0 3px;
|
||||
border-color: var(--color-e-nearwhite) transparent transparent transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer {
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
|
||||
&.selected {
|
||||
background: var(--color-5-dullgray);
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
.layer-type-icon {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 4px;
|
||||
|
||||
input {
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none; // Ok for input element
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
|
||||
&:disabled {
|
||||
-webkit-user-select: none; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release
|
||||
user-select: none;
|
||||
// Workaround for `user-select: none` not working on <input> elements
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: var(--color-1-nearblack);
|
||||
padding: 0 4px;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
margin: 2px 0;
|
||||
margin-left: 4px;
|
||||
background: white;
|
||||
border-radius: 2px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
svg {
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.insert-folder .layer {
|
||||
outline: 3px solid var(--color-e-nearwhite);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.insert-mark {
|
||||
position: absolute;
|
||||
// `left` is applied dynamically
|
||||
right: 0;
|
||||
background: var(--color-e-nearwhite);
|
||||
margin-top: -2px;
|
||||
height: 5px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,761 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from "svelte";
|
||||
|
||||
import type { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import { UpdateNodeGraphSelection, type FrontendNodeLink, type FrontendNodeType, type FrontendNode } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
import TextInput from "@/components/widgets/inputs/TextInput.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { type NodeGraphState } from "@/state-providers/node-graph";
|
||||
|
||||
const WHEEL_RATE = (1 / 600) * 3;
|
||||
const GRID_COLLAPSE_SPACING = 10;
|
||||
const GRID_SIZE = 24;
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const nodeGraph = getContext<NodeGraphState>("nodeGraph");
|
||||
|
||||
let graph: LayoutRow;
|
||||
let nodesContainer: HTMLDivElement;
|
||||
let nodeSearchInput: TextInput;
|
||||
let transform = { scale: 1, x: 0, y: 0 };
|
||||
let panning = false;
|
||||
let selected: bigint[] = [];
|
||||
let draggingNodes: { startX: number; startY: number; roundX: number; roundY: number } | undefined = undefined;
|
||||
let selectIfNotDragged: undefined | bigint = undefined;
|
||||
let linkInProgressFromConnector: HTMLDivElement | undefined = undefined;
|
||||
let linkInProgressToConnector: HTMLDivElement | DOMRect | undefined = undefined;
|
||||
let disconnecting: { nodeId: bigint; inputIndex: number; linkIndex: number } | undefined = undefined;
|
||||
let nodeLinkPaths: [string, string][] = [];
|
||||
let searchTerm = "";
|
||||
let nodeListLocation: { x: number; y: number } | undefined = undefined;
|
||||
|
||||
$: gridSpacing = calculateGridSpacing(transform.scale);
|
||||
$: dotRadius = 1 + Math.floor(transform.scale - 0.5 + 0.001) / 2;
|
||||
$: nodeGraphBarLayout = $nodeGraph.nodeGraphBarLayout;
|
||||
$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
|
||||
$: nodeListX = ((nodeListLocation?.x || 0) * GRID_SIZE + transform.x) * transform.scale;
|
||||
$: nodeListY = ((nodeListLocation?.y || 0) * GRID_SIZE + transform.y) * transform.scale;
|
||||
$: linkPathInProgress = createLinkPathInProgress(linkInProgressFromConnector, linkInProgressToConnector);
|
||||
$: linkPaths = createLinkPaths(linkPathInProgress, nodeLinkPaths);
|
||||
|
||||
$: watchNodes($nodeGraph.nodes);
|
||||
|
||||
function calculateGridSpacing(scale: number): number {
|
||||
const dense = scale * GRID_SIZE;
|
||||
let sparse = dense;
|
||||
|
||||
while (sparse > 0 && sparse < GRID_COLLAPSE_SPACING) {
|
||||
sparse *= 2;
|
||||
}
|
||||
|
||||
return sparse;
|
||||
}
|
||||
|
||||
function buildNodeCategories(nodeTypes: FrontendNodeType[], searchTerm: string) {
|
||||
const categories = new Map();
|
||||
nodeTypes.forEach((node) => {
|
||||
if (searchTerm.length > 0 && !node.name.toLowerCase().includes(searchTerm.toLowerCase()) && !node.category.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = categories.get(node.category);
|
||||
if (category) category.push(node);
|
||||
else categories.set(node.category, [node]);
|
||||
});
|
||||
|
||||
return Array.from(categories);
|
||||
}
|
||||
|
||||
function createLinkPathInProgress(linkInProgressFromConnector?: HTMLDivElement, linkInProgressToConnector?: HTMLDivElement | DOMRect): [string, string] | undefined {
|
||||
if (linkInProgressFromConnector && linkInProgressToConnector) {
|
||||
return createWirePath(linkInProgressFromConnector, linkInProgressToConnector, false, false);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createLinkPaths(linkPathInProgress: [string, string] | undefined, nodeLinkPaths: [string, string][]): [string, string][] {
|
||||
const optionalTuple = linkPathInProgress ? [linkPathInProgress] : [];
|
||||
return [...optionalTuple, ...nodeLinkPaths];
|
||||
}
|
||||
|
||||
async function watchNodes(nodes: FrontendNode[]) {
|
||||
selected = selected.filter((id) => nodes.find((node) => node.id === id));
|
||||
await refreshLinks();
|
||||
}
|
||||
|
||||
function resolveLink(link: FrontendNodeLink, containerBounds: HTMLDivElement): { nodePrimaryOutput: HTMLDivElement | undefined; nodePrimaryInput: HTMLDivElement | undefined } {
|
||||
const connectorIndex = Number(link.linkEndInputIndex);
|
||||
|
||||
const nodePrimaryOutput = (containerBounds.querySelector(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined) as HTMLDivElement | undefined;
|
||||
|
||||
const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined;
|
||||
const nodePrimaryInput = nodeInputConnectors?.[connectorIndex] as HTMLDivElement | undefined;
|
||||
return { nodePrimaryOutput, nodePrimaryInput };
|
||||
}
|
||||
|
||||
async function refreshLinks(): Promise<void> {
|
||||
await tick();
|
||||
|
||||
const links = $nodeGraph.links;
|
||||
nodeLinkPaths = links.flatMap((link, index) => {
|
||||
const { nodePrimaryInput, nodePrimaryOutput } = resolveLink(link, nodesContainer);
|
||||
if (!nodePrimaryInput || !nodePrimaryOutput) return [];
|
||||
if (disconnecting?.linkIndex === index) return [];
|
||||
|
||||
return [createWirePath(nodePrimaryOutput, nodePrimaryInput.getBoundingClientRect(), false, false)];
|
||||
});
|
||||
}
|
||||
|
||||
function nodeIcon(nodeName: string): IconName {
|
||||
const iconMap: Record<string, IconName> = {
|
||||
Output: "NodeOutput",
|
||||
Imaginate: "NodeImaginate",
|
||||
"Hue Shift Image": "NodeColorCorrection",
|
||||
"Brighten Image": "NodeColorCorrection",
|
||||
"Grayscale Image": "NodeColorCorrection",
|
||||
};
|
||||
return iconMap[nodeName] || "NodeNodes";
|
||||
}
|
||||
|
||||
function buildWirePathLocations(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): { x: number; y: number }[] {
|
||||
const containerBounds = nodesContainer.getBoundingClientRect();
|
||||
|
||||
const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1;
|
||||
const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2;
|
||||
const outConnectorX = (outX - containerBounds.x) / transform.scale;
|
||||
const outConnectorY = (outY - containerBounds.y) / transform.scale;
|
||||
|
||||
const inX = verticalIn ? inputBounds.x + inputBounds.width / 2 : inputBounds.x + 1;
|
||||
const inY = verticalIn ? inputBounds.y + inputBounds.height - 1 : inputBounds.y + inputBounds.height / 2;
|
||||
const inConnectorX = (inX - containerBounds.x) / transform.scale;
|
||||
const inConnectorY = (inY - containerBounds.y) / transform.scale;
|
||||
const horizontalGap = Math.abs(outConnectorX - inConnectorX);
|
||||
const verticalGap = Math.abs(outConnectorY - inConnectorY);
|
||||
|
||||
const curveLength = 200;
|
||||
const curveFalloffRate = curveLength * Math.PI * 2;
|
||||
|
||||
const horizontalCurveAmount = -(2 ** ((-10 * horizontalGap) / curveFalloffRate)) + 1;
|
||||
const verticalCurveAmount = -(2 ** ((-10 * verticalGap) / curveFalloffRate)) + 1;
|
||||
const horizontalCurve = horizontalCurveAmount * curveLength;
|
||||
const verticalCurve = verticalCurveAmount * curveLength;
|
||||
|
||||
return [
|
||||
{ x: outConnectorX, y: outConnectorY },
|
||||
{ x: verticalOut ? outConnectorX : outConnectorX + horizontalCurve, y: verticalOut ? outConnectorY - verticalCurve : outConnectorY },
|
||||
{ x: verticalIn ? inConnectorX : inConnectorX - horizontalCurve, y: verticalIn ? inConnectorY + verticalCurve : inConnectorY },
|
||||
{ x: inConnectorX, y: inConnectorY },
|
||||
];
|
||||
}
|
||||
|
||||
function buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string {
|
||||
const locations = buildWirePathLocations(outputBounds, inputBounds, verticalOut, verticalIn);
|
||||
if (locations.length === 0) return "[error]";
|
||||
return `M${locations[0].x},${locations[0].y} C${locations[1].x},${locations[1].y} ${locations[2].x},${locations[2].y} ${locations[3].x},${locations[3].y}`;
|
||||
}
|
||||
|
||||
function createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] {
|
||||
const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort;
|
||||
|
||||
const pathString = buildWirePathString(outputPort.getBoundingClientRect(), inputPortRect, verticalOut, verticalIn);
|
||||
const dataType = outputPort.getAttribute("data-datatype") || "general";
|
||||
|
||||
return [pathString, dataType];
|
||||
}
|
||||
|
||||
function scroll(e: WheelEvent) {
|
||||
const scrollX = e.deltaX;
|
||||
const scrollY = e.deltaY;
|
||||
|
||||
// Zoom
|
||||
if (e.ctrlKey) {
|
||||
let zoomFactor = 1 + Math.abs(scrollY) * WHEEL_RATE;
|
||||
if (scrollY > 0) zoomFactor = 1 / zoomFactor;
|
||||
|
||||
const { x, y, width, height } = graph.getBoundingClientRect();
|
||||
|
||||
transform.scale *= zoomFactor;
|
||||
|
||||
const newViewportX = width / zoomFactor;
|
||||
const newViewportY = height / zoomFactor;
|
||||
|
||||
const deltaSizeX = width - newViewportX;
|
||||
const deltaSizeY = height - newViewportY;
|
||||
|
||||
const deltaX = deltaSizeX * ((e.x - x) / width);
|
||||
const deltaY = deltaSizeY * ((e.y - y) / height);
|
||||
|
||||
transform.x -= (deltaX / transform.scale) * zoomFactor;
|
||||
transform.y -= (deltaY / transform.scale) * zoomFactor;
|
||||
|
||||
// Prevent actually zooming into the page when pinch-zooming on laptop trackpads
|
||||
e.preventDefault();
|
||||
}
|
||||
// Pan
|
||||
else if (!e.shiftKey) {
|
||||
transform.x -= scrollX / transform.scale;
|
||||
transform.y -= scrollY / transform.scale;
|
||||
} else {
|
||||
transform.x -= scrollY / transform.scale;
|
||||
}
|
||||
}
|
||||
|
||||
function keydown(e: KeyboardEvent): void {
|
||||
if (e.key.toLowerCase() === "escape") {
|
||||
nodeListLocation = undefined;
|
||||
document.removeEventListener("keydown", keydown);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the browser window) works
|
||||
function pointerDown(e: PointerEvent) {
|
||||
// Exit the add node popup by clicking elsewhere in the graph
|
||||
if (nodeListLocation && !(e.target as HTMLElement).closest("[data-node-list]")) nodeListLocation = undefined;
|
||||
|
||||
// Handle the add node popup on right click
|
||||
if (e.button === 2) {
|
||||
const graphBounds = graph.getBoundingClientRect();
|
||||
nodeListLocation = {
|
||||
x: Math.round(((e.clientX - graphBounds.x) / transform.scale - transform.x) / GRID_SIZE),
|
||||
y: Math.round(((e.clientY - graphBounds.y) / transform.scale - transform.y) / GRID_SIZE),
|
||||
};
|
||||
|
||||
// Find actual relevant child and focus it
|
||||
// TODO: Svelte: check if this works and if `setTimeout` can be removed
|
||||
setTimeout(() => nodeSearchInput.focus(), 0);
|
||||
|
||||
document.addEventListener("keydown", keydown);
|
||||
return;
|
||||
}
|
||||
|
||||
const port = (e.target as HTMLDivElement).closest("[data-port]") as HTMLDivElement;
|
||||
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
|
||||
const nodeId = node?.getAttribute("data-node") || undefined;
|
||||
const nodeList = (e.target as HTMLElement).closest("[data-node-list]") as HTMLElement | undefined;
|
||||
|
||||
// If the user is clicking on the add nodes list, exit here
|
||||
if (nodeList) return;
|
||||
|
||||
if (e.altKey && nodeId) {
|
||||
editor.instance.togglePreview(BigInt(nodeId));
|
||||
}
|
||||
|
||||
// Clicked on a port dot
|
||||
if (port && node) {
|
||||
const isOutput = Boolean(port.getAttribute("data-port") === "output");
|
||||
|
||||
if (isOutput) linkInProgressFromConnector = port;
|
||||
else {
|
||||
const inputNodeInPorts = Array.from(node.querySelectorAll(`[data-port="input"]`));
|
||||
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(port);
|
||||
const inputIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
|
||||
// Set the link to draw from the input that a previous link was on
|
||||
if (inputIndex !== undefined && nodeId) {
|
||||
const nodeIdInt = BigInt(nodeId);
|
||||
const inputIndexInt = BigInt(inputIndex);
|
||||
const links = $nodeGraph.links;
|
||||
const linkIndex = links.findIndex((value) => value.linkEnd === nodeIdInt && value.linkEndInputIndex === inputIndexInt);
|
||||
const queryString = `[data-node="${String(links[linkIndex].linkStart)}"] [data-port="output"]`;
|
||||
linkInProgressFromConnector = (nodesContainer.querySelector(queryString) || undefined) as HTMLDivElement | undefined;
|
||||
const nodeInputConnectors = nodesContainer.querySelectorAll(`[data-node="${String(links[linkIndex].linkEnd)}"] [data-port="input"]`) || undefined;
|
||||
linkInProgressToConnector = nodeInputConnectors?.[Number(links[linkIndex].linkEndInputIndex)] as HTMLDivElement | undefined;
|
||||
disconnecting = { nodeId: nodeIdInt, inputIndex, linkIndex };
|
||||
refreshLinks();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked on a node
|
||||
if (nodeId) {
|
||||
let modifiedSelected = false;
|
||||
|
||||
const id = BigInt(nodeId);
|
||||
if (e.shiftKey || e.ctrlKey) {
|
||||
modifiedSelected = true;
|
||||
|
||||
if (selected.includes(id)) selected.splice(selected.lastIndexOf(id), 1);
|
||||
else selected.push(id);
|
||||
} else if (!selected.includes(id)) {
|
||||
modifiedSelected = true;
|
||||
|
||||
selected = [id];
|
||||
} else {
|
||||
selectIfNotDragged = id;
|
||||
}
|
||||
|
||||
if (selected.includes(id)) {
|
||||
draggingNodes = { startX: e.x, startY: e.y, roundX: 0, roundY: 0 };
|
||||
}
|
||||
|
||||
if (modifiedSelected) editor.instance.selectNodes(new BigUint64Array(selected));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked on the graph background
|
||||
panning = true;
|
||||
if (selected.length !== 0) {
|
||||
selected = [];
|
||||
editor.instance.selectNodes(new BigUint64Array(selected));
|
||||
}
|
||||
}
|
||||
|
||||
function doubleClick(e: MouseEvent) {
|
||||
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
|
||||
const nodeId = node?.getAttribute("data-node") || undefined;
|
||||
if (nodeId) {
|
||||
const id = BigInt(nodeId);
|
||||
editor.instance.doubleClickNode(id);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerMove(e: PointerEvent) {
|
||||
if (panning) {
|
||||
transform.x += e.movementX / transform.scale;
|
||||
transform.y += e.movementY / transform.scale;
|
||||
} else if (linkInProgressFromConnector) {
|
||||
const target = e.target as Element | undefined;
|
||||
const dot = (target?.closest(`[data-port="input"]`) || undefined) as HTMLDivElement | undefined;
|
||||
if (dot) {
|
||||
linkInProgressToConnector = dot;
|
||||
} else {
|
||||
linkInProgressToConnector = new DOMRect(e.x, e.y);
|
||||
}
|
||||
} else if (draggingNodes) {
|
||||
const deltaX = Math.round((e.x - draggingNodes.startX) / transform.scale / GRID_SIZE);
|
||||
const deltaY = Math.round((e.y - draggingNodes.startY) / transform.scale / GRID_SIZE);
|
||||
if (draggingNodes.roundX !== deltaX || draggingNodes.roundY !== deltaY) {
|
||||
draggingNodes.roundX = deltaX;
|
||||
draggingNodes.roundY = deltaY;
|
||||
refreshLinks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUp(e: PointerEvent) {
|
||||
panning = false;
|
||||
|
||||
if (disconnecting) {
|
||||
editor.instance.disconnectNodes(BigInt(disconnecting.nodeId), disconnecting.inputIndex);
|
||||
}
|
||||
disconnecting = undefined;
|
||||
|
||||
if (linkInProgressToConnector instanceof HTMLDivElement && linkInProgressFromConnector) {
|
||||
const outputNode = linkInProgressFromConnector.closest("[data-node]");
|
||||
const inputNode = linkInProgressToConnector.closest("[data-node]");
|
||||
|
||||
const outputConnectedNodeID = outputNode?.getAttribute("data-node") ?? undefined;
|
||||
const inputConnectedNodeID = inputNode?.getAttribute("data-node") ?? undefined;
|
||||
|
||||
if (outputNode && inputNode && outputConnectedNodeID && inputConnectedNodeID) {
|
||||
const inputNodeInPorts = Array.from(inputNode.querySelectorAll(`[data-port="input"]`));
|
||||
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(linkInProgressToConnector);
|
||||
const inputNodeConnectionIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
|
||||
|
||||
if (inputNodeConnectionIndex !== undefined) {
|
||||
// const oneBasedIndex = inputNodeConnectionIndex + 1;
|
||||
|
||||
editor.instance.connectNodesByLink(BigInt(outputConnectedNodeID), BigInt(inputConnectedNodeID), inputNodeConnectionIndex);
|
||||
}
|
||||
}
|
||||
} else if (draggingNodes) {
|
||||
if (draggingNodes.startX === e.x || draggingNodes.startY === e.y) {
|
||||
if (selectIfNotDragged !== undefined && (selected.length !== 1 || selected[0] !== selectIfNotDragged)) {
|
||||
selected = [selectIfNotDragged];
|
||||
editor.instance.selectNodes(new BigUint64Array(selected));
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length > 0 && (draggingNodes.roundX !== 0 || draggingNodes.roundY !== 0)) editor.instance.moveSelectedNodes(draggingNodes.roundX, draggingNodes.roundY);
|
||||
|
||||
// Check if this node should be inserted between two other nodes
|
||||
if (selected.length === 1) {
|
||||
const selectedNodeId = selected[0];
|
||||
const selectedNode = nodesContainer.querySelector(`[data-node="${String(selectedNodeId)}"]`);
|
||||
|
||||
// Check that neither the input or output of the selected node are already connected.
|
||||
const notConnected = $nodeGraph.links.findIndex((link) => link.linkStart === selectedNodeId || (link.linkEnd === selectedNodeId && link.linkEndInputIndex === BigInt(0))) === -1;
|
||||
const input = selectedNode?.querySelector(`[data-port="input"]`);
|
||||
const output = selectedNode?.querySelector(`[data-port="output"]`);
|
||||
|
||||
// TODO: Make sure inputs are correctly typed
|
||||
if (selectedNode && notConnected && input && output) {
|
||||
// Find the link that the node has been dragged on top of
|
||||
const link = $nodeGraph.links.find((link): boolean => {
|
||||
const { nodePrimaryInput, nodePrimaryOutput } = resolveLink(link, nodesContainer);
|
||||
if (!nodePrimaryInput || !nodePrimaryOutput) return false;
|
||||
|
||||
const wireCurveLocations = buildWirePathLocations(nodePrimaryOutput.getBoundingClientRect(), nodePrimaryInput.getBoundingClientRect(), false, false);
|
||||
|
||||
const selectedNodeBounds = selectedNode.getBoundingClientRect();
|
||||
const containerBoundsBounds = nodesContainer.getBoundingClientRect();
|
||||
|
||||
return editor.instance.rectangleIntersects(
|
||||
new Float64Array(wireCurveLocations.map((loc) => loc.x)),
|
||||
new Float64Array(wireCurveLocations.map((loc) => loc.y)),
|
||||
selectedNodeBounds.top - containerBoundsBounds.y,
|
||||
selectedNodeBounds.left - containerBoundsBounds.x,
|
||||
selectedNodeBounds.bottom - containerBoundsBounds.y,
|
||||
selectedNodeBounds.right - containerBoundsBounds.x
|
||||
);
|
||||
});
|
||||
// If the node has been dragged on top of the link then connect it into the middle.
|
||||
if (link) {
|
||||
editor.instance.connectNodesByLink(link.linkStart, selectedNodeId, 0);
|
||||
editor.instance.connectNodesByLink(selectedNodeId, link.linkEnd, Number(link.linkEndInputIndex));
|
||||
editor.instance.shiftNode(selectedNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draggingNodes = undefined;
|
||||
selectIfNotDragged = undefined;
|
||||
}
|
||||
|
||||
linkInProgressFromConnector = undefined;
|
||||
linkInProgressToConnector = undefined;
|
||||
}
|
||||
|
||||
function createNode(nodeType: string): void {
|
||||
if (!nodeListLocation) return;
|
||||
|
||||
editor.instance.createNode(nodeType, nodeListLocation.x, nodeListLocation.y);
|
||||
nodeListLocation = undefined;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const outputPort1 = document.querySelectorAll(`[data-port="output"]`)[4] as HTMLDivElement | undefined;
|
||||
const inputPort1 = document.querySelectorAll(`[data-port="input"]`)[1] as HTMLDivElement | undefined;
|
||||
if (outputPort1 && inputPort1) createWirePath(outputPort1, inputPort1.getBoundingClientRect(), true, true);
|
||||
|
||||
const outputPort2 = document.querySelectorAll(`[data-port="output"]`)[6] as HTMLDivElement | undefined;
|
||||
const inputPort2 = document.querySelectorAll(`[data-port="input"]`)[3] as HTMLDivElement | undefined;
|
||||
if (outputPort2 && inputPort2) createWirePath(outputPort2, inputPort2.getBoundingClientRect(), true, false);
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphSelection, (updateNodeGraphSelection) => {
|
||||
selected = updateNodeGraphSelection.selected;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol class="node-graph">
|
||||
<LayoutRow class="options-bar"><WidgetLayout layout={nodeGraphBarLayout} /></LayoutRow>
|
||||
<LayoutRow
|
||||
class="graph"
|
||||
bind:this={graph}
|
||||
on:wheel={scroll}
|
||||
on:pointerdown={pointerDown}
|
||||
on:pointermove={pointerMove}
|
||||
on:pointerup={pointerUp}
|
||||
on:dblclick={doubleClick}
|
||||
styles={{
|
||||
"--grid-spacing": `${gridSpacing}px`,
|
||||
"--grid-offset-x": `${transform.x * transform.scale}px`,
|
||||
"--grid-offset-y": `${transform.y * transform.scale}px`,
|
||||
"--dot-radius": `${dotRadius}px`,
|
||||
}}
|
||||
>
|
||||
{#if nodeListLocation}
|
||||
<LayoutCol class="node-list" data-node-list styles={{ "margin-left": `${nodeListX}px`, "margin-top": `${nodeListY}px` }}>
|
||||
<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
|
||||
{#each nodeCategories as nodeCategory (nodeCategory[0])}
|
||||
<LayoutCol>
|
||||
<TextLabel>{nodeCategory[0]}</TextLabel>
|
||||
{#each nodeCategory[1] as nodeType (String(nodeType))}
|
||||
<TextButton label={nodeType.name} action={() => createNode(nodeType.name)} />
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{:else}
|
||||
<TextLabel>No search results</TextLabel>
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
<div class="nodes" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`} bind:this={nodesContainer}>
|
||||
{#each $nodeGraph.nodes as node (String(node.id))}
|
||||
<div
|
||||
class="node"
|
||||
class:selected={selected.includes(node.id)}
|
||||
class:output={node.output}
|
||||
class:disabled={node.disabled}
|
||||
style:--offset-left={(node.position?.x || 0) + (selected.includes(node.id) ? draggingNodes?.roundX || 0 : 0)}
|
||||
style:--offset-top={(node.position?.y || 0) + (selected.includes(node.id) ? draggingNodes?.roundY || 0 : 0)}
|
||||
data-node={node.id}
|
||||
>
|
||||
<div class="primary">
|
||||
<div class="ports">
|
||||
{#if node.primaryInput}
|
||||
<div
|
||||
class="input port"
|
||||
data-port="input"
|
||||
data-datatype={node.primaryInput}
|
||||
style:--data-color={`var(--color-data-${node.primaryInput})`}
|
||||
style:--data-color-dim={`var(--color-data-${node.primaryInput}-dim)`}
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
{/if}
|
||||
{#if node.outputs.length > 0}
|
||||
<div
|
||||
class="output port"
|
||||
data-port="output"
|
||||
data-datatype={node.outputs[0]}
|
||||
style:--data-color={`var(--color-data-${node.outputs[0]})`}
|
||||
style:--data-color-dim={`var(--color-data-${node.outputs[0]}-dim)`}
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<IconLabel icon={nodeIcon(node.displayName)} />
|
||||
<TextLabel>{node.displayName}</TextLabel>
|
||||
</div>
|
||||
{#if node.exposedInputs.length > 0}
|
||||
<div class="arguments">
|
||||
{#each node.exposedInputs as argument, index (index)}
|
||||
<div class="argument">
|
||||
<div class="ports">
|
||||
<div
|
||||
class="input port"
|
||||
data-port="input"
|
||||
data-datatype={argument.dataType}
|
||||
style:--data-color={`var(--color-data-${argument.dataType})`}
|
||||
style:--data-color-dim={`var(--color-data-${argument.dataType}-dim)`}
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<TextLabel>{argument.name}</TextLabel>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="wires" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`}>
|
||||
<svg>
|
||||
{#each linkPaths as [pathString, dataType], index (index)}
|
||||
<path d={pathString} style:--data-color={`var(--color-data-${dataType})`} style:--data-color-dim={`var(--color-data-${dataType}-dim)`} />
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.node-graph {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.node-list {
|
||||
width: max-content;
|
||||
position: fixed;
|
||||
padding: 5px;
|
||||
z-index: 3;
|
||||
background-color: var(--color-3-darkgray);
|
||||
|
||||
.text-button + .text-button {
|
||||
margin-left: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
margin: 0 4px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
.widget-layout {
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.graph {
|
||||
position: relative;
|
||||
background: var(--color-2-mildblack);
|
||||
width: calc(100% - 8px);
|
||||
margin-left: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
// We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: var(--grid-spacing) var(--grid-spacing);
|
||||
background-position: calc(var(--grid-offset-x) - var(--dot-radius)) calc(var(--grid-offset-y) - var(--dot-radius));
|
||||
background-image: radial-gradient(circle at var(--dot-radius) var(--dot-radius), var(--color-3-darkgray) var(--dot-radius), transparent 0);
|
||||
image-rendering: pixelated;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
}
|
||||
|
||||
.nodes,
|
||||
.wires {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.wires {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
// stroke: var(--color-data-raster-dim);
|
||||
stroke: var(--data-color-dim);
|
||||
stroke-width: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.nodes {
|
||||
.node {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-4-dimgray);
|
||||
left: calc((var(--offset-left) + 0.5) * 24px);
|
||||
top: calc((var(--offset-top) - 0.5) * 24px);
|
||||
|
||||
&.selected {
|
||||
border: 1px solid var(--color-e-nearwhite);
|
||||
margin: -1px;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-3-darkgray);
|
||||
color: var(--color-a-softgray);
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-a-softgray);
|
||||
}
|
||||
}
|
||||
|
||||
&.output {
|
||||
outline: 3px solid var(--color-data-vector);
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background: var(--color-5-dullgray);
|
||||
border-radius: 4px;
|
||||
|
||||
.icon-label {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.text-label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.arguments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.argument {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
// Squares to cover up the rounded corners of the primary area and make them have a straight edge
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--color-5-dullgray);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ports {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.port {
|
||||
position: absolute;
|
||||
margin: auto 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--data-color-dim);
|
||||
// background: var(--color-data-raster-dim);
|
||||
|
||||
div {
|
||||
background: var(--data-color);
|
||||
// background: var(--color-data-raster);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&.input {
|
||||
left: calc(-12px - 6px);
|
||||
}
|
||||
|
||||
&.output {
|
||||
right: calc(-12px - 6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import WidgetLayout from "@/components/widgets/WidgetLayout.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
let propertiesOptionsLayout = defaultWidgetLayout();
|
||||
let propertiesSectionsLayout = defaultWidgetLayout();
|
||||
|
||||
onMount(() => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
|
||||
patchWidgetLayout(propertiesOptionsLayout, updatePropertyPanelOptionsLayout);
|
||||
});
|
||||
|
||||
editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
|
||||
patchWidgetLayout(propertiesSectionsLayout, updatePropertyPanelSectionsLayout);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutCol class="properties">
|
||||
<LayoutRow class="options-bar">
|
||||
<WidgetLayout layout={propertiesOptionsLayout} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="sections" scrollableY={true}>
|
||||
<WidgetLayout layout={propertiesSectionsLayout} />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.properties {
|
||||
height: 100%;
|
||||
|
||||
.widget-layout {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.options-bar {
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sections {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { isWidgetColumn, isWidgetRow, isWidgetSection, type WidgetLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import WidgetSection from "@/components/widgets/groups/WidgetSection.svelte";
|
||||
import WidgetRow from "@/components/widgets/WidgetRow.svelte";
|
||||
|
||||
export let layout: WidgetLayout;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<!-- TODO: Refactor this component (together with `WidgetRow.svelte`) to be more logically consistent with our layout definition goals, in terms of naming and capabilities -->
|
||||
<div class={`widget-layout ${className} ${extraClasses}`.trim()}>
|
||||
{#each layout.layout as layoutGroup, index (index)}
|
||||
{#if isWidgetColumn(layoutGroup) || isWidgetRow(layoutGroup)}
|
||||
<WidgetRow widgetData={layoutGroup} layoutTarget={layout.layoutTarget} />
|
||||
{:else if isWidgetSection(layoutGroup)}
|
||||
<WidgetSection widgetData={layoutGroup} layoutTarget={layout.layoutTarget} />
|
||||
{:else}
|
||||
<span style="color: #d6536e">Error: The widget row that belongs here has an invalid layout group type</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.widget-layout {
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
import { debouncer } from "@/utility-functions/debounce";
|
||||
import { narrowWidgetProps, Widget } from "@/wasm-communication/messages";
|
||||
import { isWidgetColumn, isWidgetRow, type WidgetColumn, type WidgetRow } from "@/wasm-communication/messages";
|
||||
|
||||
import PivotAssist from "@/components/widgets/assists/PivotAssist.svelte";
|
||||
import BreadcrumbTrailButtons from "@/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import ParameterExposeButton from "@/components/widgets/buttons/ParameterExposeButton.svelte";
|
||||
import PopoverButton from "@/components/widgets/buttons/PopoverButton.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.svelte";
|
||||
import ColorInput from "@/components/widgets/inputs/ColorInput.svelte";
|
||||
import DropdownInput from "@/components/widgets/inputs/DropdownInput.svelte";
|
||||
import FontInput from "@/components/widgets/inputs/FontInput.svelte";
|
||||
import LayerReferenceInput from "@/components/widgets/inputs/LayerReferenceInput.svelte";
|
||||
import NumberInput from "@/components/widgets/inputs/NumberInput.svelte";
|
||||
import OptionalInput from "@/components/widgets/inputs/OptionalInput.svelte";
|
||||
import RadioInput from "@/components/widgets/inputs/RadioInput.svelte";
|
||||
import SwatchPairInput from "@/components/widgets/inputs/SwatchPairInput.svelte";
|
||||
import TextAreaInput from "@/components/widgets/inputs/TextAreaInput.svelte";
|
||||
import TextInput from "@/components/widgets/inputs/TextInput.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import Separator from "@/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const SUFFIX_WIDGETS = ["PopoverButton"];
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
export let widgetData: WidgetColumn | WidgetRow;
|
||||
export let layoutTarget: any;
|
||||
|
||||
$: direction = watchDirection(widgetData);
|
||||
$: widgets = watchWidgets(widgetData);
|
||||
$: widgetsAndNextSiblingIsSuffix = watchWidgetsAndNextSiblingIsSuffix(widgets);
|
||||
|
||||
function watchDirection(widgetData: WidgetRow | WidgetColumn): "row" | "column" | "ERROR" {
|
||||
if (isWidgetRow(widgetData)) return "row";
|
||||
if (isWidgetColumn(widgetData)) return "column";
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
function watchWidgets(widgetData: WidgetRow | WidgetColumn): Widget[] {
|
||||
let widgets: Widget[] = [];
|
||||
if (isWidgetRow(widgetData)) widgets = widgetData.rowWidgets;
|
||||
else if (isWidgetColumn(widgetData)) widgets = widgetData.columnWidgets;
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function watchWidgetsAndNextSiblingIsSuffix(widgets: Widget[]): [Widget, boolean][] {
|
||||
return widgets.map((widget, index): [Widget, boolean] => {
|
||||
// A suffix widget is one that joins up with this widget at the end with only a 1px gap.
|
||||
// It uses the CSS sibling selector to give its own left edge corners zero radius.
|
||||
// But this JS is needed to set its preceding sibling widget's right edge corners to zero radius.
|
||||
const nextSiblingIsSuffix = SUFFIX_WIDGETS.includes(widgets[index + 1]?.props.kind);
|
||||
|
||||
return [widget, nextSiblingIsSuffix];
|
||||
});
|
||||
}
|
||||
|
||||
function updateLayout(index: number, value: unknown) {
|
||||
editor.instance.updateLayout(layoutTarget, widgets[index].widgetId, value);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// function exclude<T extends Record<string, any>>(props: T, additional?: (keyof T)[]): Pick<T, Exclude<keyof T, "kind" | (typeof additional extends Array<infer K> ? K : never)>> {
|
||||
// const exclusions = ["kind", ...(additional || [])];
|
||||
|
||||
// return Object.fromEntries(Object.entries(props).filter((entry) => !exclusions.includes(entry[0]))) as any;
|
||||
// }
|
||||
|
||||
// TODO: This seems to work, but verify the correctness and terseness of this, it's adapted from https://stackoverflow.com/a/67434028/775283
|
||||
function exclude<T extends object>(props: T, additional?: (keyof T)[]): Omit<T, typeof additional extends Array<infer K> ? K : never> {
|
||||
const exclusions = ["kind", ...(additional || [])];
|
||||
|
||||
return Object.fromEntries(Object.entries(props).filter((entry) => !exclusions.includes(entry[0]))) as any;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- TODO: Refactor this component to use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
|
||||
<!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.svelte` as part of its refactor -->
|
||||
|
||||
<div class={`widget-${direction}`}>
|
||||
{#each widgetsAndNextSiblingIsSuffix as [component, nextIsSuffix], index (index)}
|
||||
{@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")}
|
||||
{#if checkboxInput}
|
||||
<CheckboxInput {...exclude(checkboxInput)} on:checked={({ detail }) => updateLayout(index, detail)} />
|
||||
{/if}
|
||||
{@const colorInput = narrowWidgetProps(component.props, "ColorInput")}
|
||||
{#if colorInput}
|
||||
<ColorInput {...exclude(colorInput)} on:value={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")}
|
||||
{#if dropdownInput}
|
||||
<DropdownInput {...exclude(dropdownInput)} on:selectedIndex={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const fontInput = narrowWidgetProps(component.props, "FontInput")}
|
||||
{#if fontInput}
|
||||
<FontInput {...exclude(fontInput)} on:changeFont={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const parameterExposeButton = narrowWidgetProps(component.props, "ParameterExposeButton")}
|
||||
{#if parameterExposeButton}
|
||||
<ParameterExposeButton {...exclude(parameterExposeButton)} action={() => updateLayout(index, undefined)} />
|
||||
{/if}
|
||||
{@const iconButton = narrowWidgetProps(component.props, "IconButton")}
|
||||
{#if iconButton}
|
||||
<IconButton {...exclude(iconButton)} action={() => updateLayout(index, undefined)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const iconLabel = narrowWidgetProps(component.props, "IconLabel")}
|
||||
{#if iconLabel}
|
||||
<IconLabel {...exclude(iconLabel)} />
|
||||
{/if}
|
||||
{@const layerReferenceInput = narrowWidgetProps(component.props, "LayerReferenceInput")}
|
||||
{#if layerReferenceInput}
|
||||
<LayerReferenceInput {...exclude(layerReferenceInput)} on:value={({ detail }) => updateLayout(index, detail)} />
|
||||
{/if}
|
||||
{@const numberInput = narrowWidgetProps(component.props, "NumberInput")}
|
||||
{#if numberInput}
|
||||
<NumberInput
|
||||
{...exclude(numberInput)}
|
||||
on:value={({ detail }) => debouncer(() => updateLayout(index, detail))}
|
||||
incrementCallbackIncrease={() => updateLayout(index, "Increment")}
|
||||
incrementCallbackDecrease={() => updateLayout(index, "Decrement")}
|
||||
sharpRightCorners={nextIsSuffix}
|
||||
/>
|
||||
{/if}
|
||||
{@const optionalInput = narrowWidgetProps(component.props, "OptionalInput")}
|
||||
{#if optionalInput}
|
||||
<OptionalInput {...exclude(optionalInput)} on:checked={({ detail }) => updateLayout(index, detail)} />
|
||||
{/if}
|
||||
{@const pivotAssist = narrowWidgetProps(component.props, "PivotAssist")}
|
||||
{#if pivotAssist}
|
||||
<PivotAssist {...exclude(pivotAssist)} on:position={({ detail }) => updateLayout(index, detail)} />
|
||||
{/if}
|
||||
{@const popoverButton = narrowWidgetProps(component.props, "PopoverButton")}
|
||||
{#if popoverButton}
|
||||
<PopoverButton {...exclude(popoverButton, ["header", "text"])}>
|
||||
<TextLabel bold={true}>{popoverButton.header}</TextLabel>
|
||||
<TextLabel multiline={true}>{popoverButton.text}</TextLabel>
|
||||
</PopoverButton>
|
||||
{/if}
|
||||
{@const radioInput = narrowWidgetProps(component.props, "RadioInput")}
|
||||
{#if radioInput}
|
||||
<RadioInput {...exclude(radioInput)} on:selectedIndex={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const separator = narrowWidgetProps(component.props, "Separator")}
|
||||
{#if separator}
|
||||
<Separator {...exclude(separator)} />
|
||||
{/if}
|
||||
{@const swatchPairInput = narrowWidgetProps(component.props, "SwatchPairInput")}
|
||||
{#if swatchPairInput}
|
||||
<SwatchPairInput {...exclude(swatchPairInput)} />
|
||||
{/if}
|
||||
{@const textAreaInput = narrowWidgetProps(component.props, "TextAreaInput")}
|
||||
{#if textAreaInput}
|
||||
<TextAreaInput {...exclude(textAreaInput)} on:commitText={({ detail }) => updateLayout(index, detail)} />
|
||||
{/if}
|
||||
{@const textButton = narrowWidgetProps(component.props, "TextButton")}
|
||||
{#if textButton}
|
||||
<TextButton {...exclude(textButton)} action={() => updateLayout(index, undefined)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const breadcrumbTrailButtons = narrowWidgetProps(component.props, "BreadcrumbTrailButtons")}
|
||||
{#if breadcrumbTrailButtons}
|
||||
<BreadcrumbTrailButtons {...exclude(breadcrumbTrailButtons)} action={(index) => updateLayout(index, index)} />
|
||||
{/if}
|
||||
{@const textInput = narrowWidgetProps(component.props, "TextInput")}
|
||||
{#if textInput}
|
||||
<TextInput {...exclude(textInput)} on:commitText={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
|
||||
{/if}
|
||||
{@const textLabel = narrowWidgetProps(component.props, "TextLabel")}
|
||||
{#if textLabel}
|
||||
<TextLabel {...exclude(textLabel, ["value"])}>{textLabel.value}</TextLabel>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.widget-column {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-row {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
|
||||
> * {
|
||||
--widget-height: 24px;
|
||||
margin: calc((24px - var(--widget-height)) / 2 + 4px) 0;
|
||||
min-height: var(--widget-height);
|
||||
|
||||
&:not(.multiline) {
|
||||
line-height: var(--widget-height);
|
||||
}
|
||||
|
||||
&.icon-label.size-12 {
|
||||
--widget-height: 12px;
|
||||
}
|
||||
|
||||
&.icon-label.size-16 {
|
||||
--widget-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Target this in a better way than using the tooltip, which will break if changed, or when localized/translated
|
||||
.checkbox-input [title="Preserve Aspect Ratio"] {
|
||||
margin-bottom: -32px;
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--color-7-middlegray);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: calc(-4px - 16px);
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: calc(-4px - 16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type PivotPosition } from "@/wasm-communication/messages";
|
||||
|
||||
// emits: ["update:position"],
|
||||
const dispatch = createEventDispatcher<{ position: PivotPosition }>();
|
||||
|
||||
export let position: string;
|
||||
export let disabled = false;
|
||||
|
||||
function setPosition(newPosition: PivotPosition) {
|
||||
dispatch("position", newPosition);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pivot-assist" class:disabled>
|
||||
<button on:click={() => setPosition("TopLeft")} class="row-1 col-1" class:active={position === "TopLeft"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("TopCenter")} class="row-1 col-2" class:active={position === "TopCenter"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("TopRight")} class="row-1 col-3" class:active={position === "TopRight"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("CenterLeft")} class="row-2 col-1" class:active={position === "CenterLeft"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("Center")} class="row-2 col-2" class:active={position === "Center"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("CenterRight")} class="row-2 col-3" class:active={position === "CenterRight"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("BottomLeft")} class="row-3 col-1" class:active={position === "BottomLeft"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("BottomCenter")} class="row-3 col-2" class:active={position === "BottomCenter"} tabindex="-1" {disabled}><div /></button>
|
||||
<button on:click={() => setPosition("BottomRight")} class="row-3 col-3" class:active={position === "BottomRight"} tabindex="-1" {disabled}><div /></button>
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.pivot-assist {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--pivot-border-color: var(--color-5-dullgray);
|
||||
--pivot-fill-active: var(--color-e-nearwhite);
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-1-nearblack);
|
||||
border: 1px solid var(--pivot-border-color);
|
||||
|
||||
&.active {
|
||||
border-color: transparent;
|
||||
background: var(--pivot-fill-active);
|
||||
}
|
||||
|
||||
&.col-1::before,
|
||||
&.col-2::before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
width: 2px;
|
||||
height: 0;
|
||||
border-top: 1px solid var(--pivot-border-color);
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
&.row-1::after,
|
||||
&.row-2::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
border-left: 1px solid var(--pivot-border-color);
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
&.row-1 {
|
||||
top: 3px;
|
||||
}
|
||||
&.col-1 {
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
&.row-2 {
|
||||
top: 10px;
|
||||
}
|
||||
&.col-2 {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
&.row-3 {
|
||||
top: 17px;
|
||||
}
|
||||
&.col-3 {
|
||||
left: 17px;
|
||||
}
|
||||
|
||||
// Click targets that extend 1px beyond the borders of each square
|
||||
div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2px;
|
||||
margin: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.disabled) button:not(.active):hover {
|
||||
border-color: transparent;
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
&.disabled button {
|
||||
--pivot-border-color: var(--color-4-dimgray);
|
||||
--pivot-fill-active: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
|
||||
export let labels: string[];
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (index: number) => void;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="breadcrumb-trail-buttons" {tooltip}>
|
||||
{#each labels as label, index (index)}
|
||||
<TextButton {label} emphasized={index === labels.length - 1} {disabled} action={() => !disabled && index !== labels.length - 1 && action(index)} />
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.breadcrumb-trail-buttons {
|
||||
.text-button {
|
||||
position: relative;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -4px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 4px;
|
||||
border-color: var(--button-background-color) var(--button-background-color) var(--button-background-color) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 12px 0 12px 4px;
|
||||
border-color: transparent transparent transparent var(--button-background-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
// Make this non-functional button not change color on hover
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { type IconName, type IconSize } from "@/utility-functions/icons";
|
||||
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
|
||||
export let icon: IconName;
|
||||
export let size: IconSize;
|
||||
export let disabled = false;
|
||||
export let active = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
// Callbacks
|
||||
export let action: (e?: MouseEvent) => void;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={`icon-button size-${size} ${className} ${extraClasses}`.trim()}
|
||||
class:disabled
|
||||
class:active
|
||||
class:sharp-right-corners={sharpRightCorners}
|
||||
on:click={action}
|
||||
{disabled}
|
||||
title={tooltip}
|
||||
tabindex={active ? -1 : 0}
|
||||
{...$$restProps}
|
||||
>
|
||||
<IconLabel {icon} />
|
||||
</button>
|
||||
|
||||
<style lang="scss" global>
|
||||
.icon-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
// The `where` pseudo-class does not contribtue to specificity
|
||||
& + :where(.icon-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-e-nearwhite);
|
||||
|
||||
svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
&.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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
export let exposed: boolean;
|
||||
export let dataType: string;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
// Callbacks
|
||||
export let action: (e?: MouseEvent) => void;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="parameter-expose-button">
|
||||
<button class:exposed style:--data-type-color={`var(--color-data-${dataType})`} on:click={action} title={tooltip} tabindex="0" />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.parameter-expose-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
max-height: 24px;
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
&:not(.exposed) {
|
||||
background: none;
|
||||
border: 1px solid var(--data-type-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
|
||||
&.exposed {
|
||||
background: var(--data-type-color);
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
|
||||
import FloatingMenu from "@/components/layout/FloatingMenu.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
|
||||
export let icon: IconName = "DropdownArrow";
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let disabled = false;
|
||||
// Callbacks
|
||||
export let action: (() => void) | undefined = undefined;
|
||||
|
||||
let open = false;
|
||||
|
||||
function onClick() {
|
||||
open = true;
|
||||
action?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="popover-button">
|
||||
<IconButton classes={{ open }} {disabled} action={() => onClick()} icon={icon || "DropdownArrow"} size={16} {tooltip} data-floating-menu-spawner />
|
||||
<FloatingMenu {open} on:open={({ detail }) => (open = detail)} type="Popover" direction="Bottom">
|
||||
<slot />
|
||||
</FloatingMenu>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.popover-button {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.icon-button.icon-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--color-1-nearblack);
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-6-lowergray);
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
export let label: string;
|
||||
export let icon: IconName | undefined = undefined;
|
||||
export let emphasized: boolean = false;
|
||||
export let minWidth: number = 0;
|
||||
export let disabled: boolean = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners: boolean = false;
|
||||
|
||||
// Callbacks
|
||||
// TODO: Replace this with an event binding (and on other components that do this)
|
||||
export let action: (e: MouseEvent) => void;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="text-button"
|
||||
class:emphasized
|
||||
class:disabled
|
||||
class:sharp-right-corners={sharpRightCorners}
|
||||
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
|
||||
title={tooltip}
|
||||
data-emphasized={emphasized || undefined}
|
||||
data-disabled={disabled || undefined}
|
||||
data-text-button
|
||||
tabindex={disabled ? -1 : 0}
|
||||
on:click={action}
|
||||
>
|
||||
{#if icon}
|
||||
<IconLabel {icon} />
|
||||
{/if}
|
||||
<TextLabel>{label}</TextLabel>
|
||||
</button>
|
||||
|
||||
<style lang="scss" global>
|
||||
.text-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--button-background-color);
|
||||
color: var(--button-text-color);
|
||||
--button-background-color: var(--color-5-dullgray);
|
||||
--button-text-color: var(--color-e-nearwhite);
|
||||
|
||||
&:hover {
|
||||
--button-background-color: var(--color-6-lowergray);
|
||||
--button-text-color: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--button-background-color: var(--color-4-dimgray);
|
||||
--button-text-color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.emphasized {
|
||||
--button-background-color: var(--color-e-nearwhite);
|
||||
--button-text-color: var(--color-2-mildblack);
|
||||
|
||||
&:hover {
|
||||
--button-background-color: var(--color-f-white);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--button-background-color: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
& + .text-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
position: relative;
|
||||
left: -4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { isWidgetRow, isWidgetSection, type LayoutGroup, type WidgetSection as WidgetSectionFromJsMessages } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import WidgetRow from "@/components/widgets/WidgetRow.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
export let widgetData: WidgetSectionFromJsMessages;
|
||||
export let layoutTarget: any; // TODO: Give type
|
||||
|
||||
let expanded = true;
|
||||
</script>
|
||||
|
||||
<!-- TODO: Implement collapsable sections with properties system -->
|
||||
<LayoutCol class="widget-section">
|
||||
<button class="header" class:expanded on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0">
|
||||
<div class="expand-arrow" />
|
||||
<TextLabel bold={true}>{widgetData.name}</TextLabel>
|
||||
</button>
|
||||
{#if expanded}
|
||||
<LayoutCol class="body">
|
||||
{#each widgetData.layout as layoutGroup, index (index)}
|
||||
{#if isWidgetRow(layoutGroup)}
|
||||
<WidgetRow widgetData={layoutGroup} {layoutTarget} />
|
||||
{:else if isWidgetSection(layoutGroup)}
|
||||
<svelte:self widgetData={layoutGroup} {layoutTarget} />
|
||||
{:else}
|
||||
<span style="color: #d6536e">Error: The widget that belongs here has an invalid layout group type</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.widget-section {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.header {
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 24px;
|
||||
padding: 0 8px;
|
||||
margin-bottom: 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--color-5-dullgray);
|
||||
|
||||
.expand-arrow {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--icon-expand-collapse-arrow);
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
border-radius: 4px 4px 0 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
.expand-arrow::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
height: 18px;
|
||||
margin-left: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
.expand-arrow::after {
|
||||
background: var(--icon-expand-collapse-arrow-hover);
|
||||
}
|
||||
|
||||
.text-label {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
|
||||
+ .body {
|
||||
border: 1px solid var(--color-6-lowergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 7px;
|
||||
padding-top: 1px;
|
||||
margin-top: -1px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid var(--color-5-dullgray);
|
||||
border-radius: 0 0 4px 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.widget-row {
|
||||
&:first-child {
|
||||
margin-top: calc(4px - 1px);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: calc(4px - 1px);
|
||||
}
|
||||
|
||||
> .text-button:first-child {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
> .text-label:first-of-type {
|
||||
flex: 0 0 25%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
> .parameter-expose-button ~ .text-label:first-of-type {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
> .text-button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
> .radio-input button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
|
||||
// emits: ["update:checked"],
|
||||
const dispatch = createEventDispatcher<{ checked: boolean }>();
|
||||
|
||||
export let checked = false;
|
||||
export let disabled = false;
|
||||
export let icon: IconName = "Checkmark";
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
let inputElement: HTMLInputElement;
|
||||
let id = `${Math.random()}`.substring(2);
|
||||
|
||||
$: displayIcon = (!checked && icon === "Checkmark" ? "Empty12px" : icon) as IconName;
|
||||
|
||||
export function isChecked() {
|
||||
return checked;
|
||||
}
|
||||
|
||||
export function input(): HTMLInputElement {
|
||||
return inputElement;
|
||||
}
|
||||
|
||||
function toggleCheckboxFromLabel(e: KeyboardEvent) {
|
||||
const target = (e.target || undefined) as HTMLLabelElement | undefined;
|
||||
const previousSibling = (target?.previousSibling || undefined) as HTMLInputElement | undefined;
|
||||
previousSibling?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="checkbox-input">
|
||||
<input type="checkbox" id={`checkbox-input-${id}`} {checked} on:change={(e) => dispatch("checked", inputElement.checked)} {disabled} tabindex={disabled ? -1 : 0} bind:this={inputElement} />
|
||||
<label class:disabled class:checked for={`checkbox-input-${id}`} on:keydown={(e) => e.key === "Enter" && toggleCheckboxFromLabel(e)} title={tooltip}>
|
||||
<LayoutRow class="checkbox-box">
|
||||
<IconLabel icon={displayIcon} />
|
||||
</LayoutRow>
|
||||
</label>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.checkbox-input {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
// We can't use `display: none` because it must be visible to work as a tabbale input that accepts a space bar actuation
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Unchecked
|
||||
label {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
// Provides rounded corners for the :focus outline
|
||||
border-radius: 2px;
|
||||
|
||||
.checkbox-box {
|
||||
flex: 0 0 auto;
|
||||
background: var(--color-5-dullgray);
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovered
|
||||
&:hover .checkbox-box {
|
||||
background: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
// Disabled
|
||||
&.disabled .checkbox-box {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
|
||||
// Checked
|
||||
input:checked + label {
|
||||
.checkbox-box {
|
||||
background: var(--color-e-nearwhite);
|
||||
|
||||
.icon-label {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovered
|
||||
&:hover .checkbox-box {
|
||||
background: var(--color-f-white);
|
||||
}
|
||||
|
||||
// Hovered
|
||||
&.disabled .checkbox-box {
|
||||
background: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { Color } from "@/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
// emits: ["update:value"],
|
||||
const dispatch = createEventDispatcher<{ value: Color }>();
|
||||
|
||||
let open = false;
|
||||
|
||||
export let value: Color;
|
||||
export let noTransparency = false; // TODO: Rename to allowTransparency, also implement allowNone
|
||||
export let disabled = false; // TODO: Design and implement
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
// TODO: Implement
|
||||
$: chip = undefined;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="color-input" classes={{ "sharp-right-corners": sharpRightCorners }} {tooltip}>
|
||||
<button
|
||||
class:none={value.none}
|
||||
class:sharp-right-corners={sharpRightCorners}
|
||||
style:--chosen-color={value.toHexOptionalAlpha()}
|
||||
on:click={() => (open = true)}
|
||||
tabindex="0"
|
||||
data-floating-menu-spawner
|
||||
>
|
||||
{#if chip}
|
||||
<TextLabel class="chip" bold={true}>{chip}</TextLabel>
|
||||
{/if}
|
||||
</button>
|
||||
<ColorPicker {open} on:open={({ detail }) => (open = detail)} color={value} on:color={({ detail }) => dispatch("value", detail)} allowNone={true} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.color-input {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
border: 1px solid var(--color-5-dullgray);
|
||||
border-radius: 2px;
|
||||
padding: 1px;
|
||||
|
||||
> button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2px;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
background: linear-gradient(var(--chosen-color), var(--chosen-color)), var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
}
|
||||
|
||||
&.none {
|
||||
background: var(--color-none);
|
||||
background-repeat: var(--color-none-repeat);
|
||||
background-position: var(--color-none-position);
|
||||
background-size: var(--color-none-size-24px);
|
||||
background-image: var(--color-none-image-24px);
|
||||
}
|
||||
|
||||
.chip {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: 0;
|
||||
height: 13px;
|
||||
line-height: 13px;
|
||||
background: var(--color-f-white);
|
||||
color: var(--color-2-mildblack);
|
||||
border-radius: 4px 0 0 0;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 0 2px var(--color-3-darkgray);
|
||||
}
|
||||
}
|
||||
|
||||
&.color-input.color-input > button {
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
> .floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type MenuListEntry } from "@/wasm-communication/messages";
|
||||
|
||||
import MenuList from "@/components/floating-menus/MenuList.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
const DASH_ENTRY = { label: "-" };
|
||||
|
||||
// emits: ["update:selectedIndex"],
|
||||
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
|
||||
|
||||
let menuList: MenuList;
|
||||
let self: LayoutRow;
|
||||
|
||||
export let entries: MenuListEntry[][];
|
||||
export let selectedIndex: number | undefined = undefined; // When not provided, a dash is displayed
|
||||
export let drawIcon = false;
|
||||
export let interactive = true;
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
let activeEntry = makeActiveEntry();
|
||||
let activeEntrySkipWatcher = false;
|
||||
let open = false;
|
||||
let minWidth = 0;
|
||||
|
||||
$: selectedIndex, watchSelectedIndex();
|
||||
$: watchActiveEntry(activeEntry);
|
||||
|
||||
// Called only when `selectedIndex` is changed from outside this component (with v-model)
|
||||
function watchSelectedIndex() {
|
||||
activeEntrySkipWatcher = true;
|
||||
activeEntry = makeActiveEntry();
|
||||
}
|
||||
|
||||
// Called when `activeEntry` is changed by the `v-model` on this component's MenuList component, or by the `selectedIndex()` watcher above (but we want to skip that case)
|
||||
function watchActiveEntry(activeEntry: MenuListEntry) {
|
||||
if (activeEntrySkipWatcher) {
|
||||
activeEntrySkipWatcher = false;
|
||||
} else if (activeEntry !== DASH_ENTRY) {
|
||||
dispatch("selectedIndex", entries.flat().indexOf(activeEntry));
|
||||
}
|
||||
}
|
||||
|
||||
function makeActiveEntry(): MenuListEntry {
|
||||
const allEntries = entries.flat();
|
||||
|
||||
if (selectedIndex !== undefined && selectedIndex >= 0 && selectedIndex < allEntries.length) {
|
||||
return allEntries[selectedIndex];
|
||||
}
|
||||
return DASH_ENTRY;
|
||||
}
|
||||
|
||||
function unFocusDropdownBox(e: FocusEvent) {
|
||||
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]");
|
||||
if (blurTarget !== self.div()) open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="dropdown-input" bind:this={self} data-dropdown-input>
|
||||
<LayoutRow
|
||||
class="dropdown-box"
|
||||
classes={{ disabled, open, "sharp-right-corners": sharpRightCorners }}
|
||||
styles={{ minWidth: `${minWidth}px` }}
|
||||
{tooltip}
|
||||
on:click={() => !disabled && (open = true)}
|
||||
on:blur={unFocusDropdownBox}
|
||||
on:keydown={(e) => menuList.keydown(e, false)}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
data-floating-menu-spawner
|
||||
>
|
||||
{#if activeEntry.icon}
|
||||
<IconLabel class="dropdown-icon" icon={activeEntry.icon} />
|
||||
{/if}
|
||||
<TextLabel class="dropdown-label">{activeEntry.label}</TextLabel>
|
||||
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||
</LayoutRow>
|
||||
<MenuList
|
||||
on:naturalWidth={({ detail }) => (minWidth = detail)}
|
||||
{activeEntry}
|
||||
on:activeEntry={({ detail }) => (activeEntry = detail)}
|
||||
{open}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
{entries}
|
||||
{drawIcon}
|
||||
{interactive}
|
||||
direction="Bottom"
|
||||
scrollableY={true}
|
||||
bind:this={menuList}
|
||||
/>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.dropdown-input {
|
||||
position: relative;
|
||||
|
||||
.dropdown-box {
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
background: var(--color-1-nearblack);
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
|
||||
.dropdown-label {
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin: 4px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
& + .dropdown-label {
|
||||
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>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
// emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
|
||||
const dispatch = createEventDispatcher<{
|
||||
value: string;
|
||||
textFocused: undefined;
|
||||
textChanged: undefined;
|
||||
cancelTextChange: undefined;
|
||||
}>();
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let value: string;
|
||||
export let label: string | undefined = undefined;
|
||||
export let spellcheck = false;
|
||||
export let disabled = false;
|
||||
export let textarea = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
let id = `${Math.random()}`.substring(2);
|
||||
let macKeyboardLayout = platformIsMac();
|
||||
let inputValue = value;
|
||||
|
||||
$: dispatch("value", inputValue);
|
||||
|
||||
// Select (highlight) all the text. For technical reasons, it is necessary to pass the current text.
|
||||
// TODO: Svelte: Test if the above message is still true
|
||||
export function selectAllText(currentText: string) {
|
||||
// Setting the value directly is required to make `input.select()` work
|
||||
// TODO: Svelte: Test if the above message is still true
|
||||
input.value = currentText;
|
||||
input.select();
|
||||
}
|
||||
|
||||
export function unFocus() {
|
||||
input.blur();
|
||||
}
|
||||
|
||||
export function getInputElementValue(): string {
|
||||
return input.value;
|
||||
}
|
||||
|
||||
export function setInputElementValue(value: string) {
|
||||
input.value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
|
||||
<LayoutRow class={`field-input ${className}`} classes={{ disabled, "sharp-right-corners": sharpRightCorners, ...classes }} style={styleName} {styles} {tooltip}>
|
||||
{#if !textarea}
|
||||
<input
|
||||
type="text"
|
||||
class:has-label={label}
|
||||
id={`field-input-${id}`}
|
||||
{spellcheck}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
bind:value={inputValue}
|
||||
bind:this={input}
|
||||
on:focus={() => dispatch("textFocused")}
|
||||
on:blur={() => dispatch("textChanged")}
|
||||
on:change={() => dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Enter" && dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Escape" && dispatch("cancelTextChange")}
|
||||
data-input-element
|
||||
/>
|
||||
{:else}
|
||||
<textarea
|
||||
class:has-label={label}
|
||||
id={`field-input-${id}`}
|
||||
class="scrollable-y"
|
||||
data-scrollable-y
|
||||
{spellcheck}
|
||||
{disabled}
|
||||
bind:value={inputValue}
|
||||
bind:this={input}
|
||||
on:focus={() => dispatch("textFocused")}
|
||||
on:blur={() => dispatch("textChanged")}
|
||||
on:change={() => dispatch("textChanged")}
|
||||
on:keydown={(e) => (macKeyboardLayout ? e.metaKey : e.ctrlKey) && e.key === "Enter" && dispatch("textChanged")}
|
||||
on:keydown={(e) => e.key === "Escape" && dispatch("cancelTextChange")}
|
||||
/>
|
||||
{/if}
|
||||
{#if label}
|
||||
<label for={`field-input-${id}`}>{label}</label>
|
||||
{/if}
|
||||
<slot />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.field-input {
|
||||
min-width: 80px;
|
||||
height: auto;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
background: var(--color-1-nearblack);
|
||||
overflow: hidden;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
label {
|
||||
flex: 0 0 auto;
|
||||
line-height: 18px;
|
||||
padding: 3px 0;
|
||||
padding-right: 4px;
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:not(.disabled) label {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
flex: 1 1 100%;
|
||||
width: 0;
|
||||
min-width: 30px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
margin: 0 8px;
|
||||
padding: 3px 0;
|
||||
outline: none; // Ok for input/textarea element
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-e-nearwhite);
|
||||
caret-color: var(--color-e-nearwhite);
|
||||
|
||||
&::selection {
|
||||
background-color: var(--color-5-dullgray);
|
||||
|
||||
// Target only Safari
|
||||
@supports (background: -webkit-named-image(i)) {
|
||||
& {
|
||||
// Setting an alpha value opts out of Safari's "fancy" (but not visible on dark backgrounds) selection highlight rendering
|
||||
// https://stackoverflow.com/a/71753552/775283
|
||||
background-color: rgba(var(--color-5-dullgray-rgb), calc(254 / 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
// text-align: center;
|
||||
|
||||
&:not(:focus).has-label {
|
||||
text-align: right;
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
text-align: left;
|
||||
|
||||
& + label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: calc(18px * 3);
|
||||
margin: 3px;
|
||||
padding: 0 5px;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
label,
|
||||
input,
|
||||
textarea {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onMount, tick } from "svelte";
|
||||
|
||||
import { type MenuListEntry } from "@/wasm-communication/messages";
|
||||
|
||||
import MenuList from "@/components/floating-menus/MenuList.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { type FontsState } from "@/state-providers/fonts";
|
||||
|
||||
const fonts = getContext<FontsState>("fonts");
|
||||
|
||||
// emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
|
||||
const dispatch = createEventDispatcher<{
|
||||
fontFamily: string;
|
||||
fontStyle: string;
|
||||
changeFont: { fontFamily: string; fontStyle: string; fontFileUrl: string | undefined };
|
||||
}>();
|
||||
|
||||
let menuList: MenuList;
|
||||
|
||||
export let fontFamily: string;
|
||||
export let fontStyle: string;
|
||||
export let isStyle = false;
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
let open = false;
|
||||
let entries: MenuListEntry[] = [];
|
||||
let activeEntry: MenuListEntry | undefined = undefined;
|
||||
let highlighted: MenuListEntry | undefined = undefined;
|
||||
let minWidth = isStyle ? 0 : 300;
|
||||
|
||||
$: fontFamily,
|
||||
(async () => {
|
||||
entries = await getEntries();
|
||||
activeEntry = getActiveEntry(entries);
|
||||
highlighted = activeEntry;
|
||||
})();
|
||||
$: fontStyle,
|
||||
async () => {
|
||||
entries = await getEntries();
|
||||
activeEntry = getActiveEntry(entries);
|
||||
highlighted = activeEntry;
|
||||
};
|
||||
|
||||
async function setOpen(): Promise<void> {
|
||||
open = true;
|
||||
|
||||
// Scroll to the active entry (the scroller div does not yet exist so we must wait for the component to render)
|
||||
await tick();
|
||||
|
||||
if (activeEntry) {
|
||||
const index = entries.indexOf(activeEntry);
|
||||
menuList.scrollViewTo(Math.max(0, index * 20 - 190));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen(): void {
|
||||
if (!disabled) {
|
||||
open = !open;
|
||||
|
||||
if (open) setOpen();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFont(newName: string): Promise<void> {
|
||||
let family;
|
||||
let style;
|
||||
|
||||
if (isStyle) {
|
||||
dispatch("fontStyle", newName);
|
||||
|
||||
family = fontFamily;
|
||||
style = newName;
|
||||
} else {
|
||||
dispatch("fontFamily", newName);
|
||||
|
||||
family = newName;
|
||||
style = "Normal (400)";
|
||||
}
|
||||
|
||||
const fontFileUrl = await fonts.getFontFileUrl(family, style);
|
||||
dispatch("changeFont", { fontFamily: family, fontStyle: style, fontFileUrl });
|
||||
}
|
||||
|
||||
async function getEntries(): Promise<MenuListEntry[]> {
|
||||
const x = isStyle ? fonts.getFontStyles(fontFamily) : fonts.fontNames();
|
||||
return (await x).map((entry: { name: string; url: URL | undefined }) => ({
|
||||
label: entry.name,
|
||||
value: entry.name,
|
||||
font: entry.url,
|
||||
action: () => selectFont(entry.name),
|
||||
}));
|
||||
}
|
||||
|
||||
function getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
|
||||
const selectedChoice = isStyle ? fontStyle : fontFamily;
|
||||
|
||||
return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
entries = await getEntries();
|
||||
|
||||
activeEntry = getActiveEntry(entries);
|
||||
highlighted = activeEntry;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- TODO: Combine this widget into the DropdownInput widget -->
|
||||
<LayoutRow class="font-input">
|
||||
<LayoutRow
|
||||
class="dropdown-box"
|
||||
classes={{ disabled, "sharp-right-corners": sharpRightCorners }}
|
||||
styles={{ minWidth: `${minWidth}px` }}
|
||||
{tooltip}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
on:click={toggleOpen}
|
||||
on:keydown={(e) => menuList.keydown(e, false)}
|
||||
data-floating-menu-spawner
|
||||
>
|
||||
<TextLabel class="dropdown-label">{activeEntry?.value || ""}</TextLabel>
|
||||
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
|
||||
</LayoutRow>
|
||||
<MenuList
|
||||
on:naturalWidth={({ detail }) => isStyle && (minWidth = detail)}
|
||||
{activeEntry}
|
||||
on:activeEntry={({ detail }) => (activeEntry = detail)}
|
||||
{open}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
entries={[entries]}
|
||||
minWidth={isStyle ? 0 : minWidth}
|
||||
virtualScrollingEntryHeight={isStyle ? 0 : 20}
|
||||
scrollableY={true}
|
||||
bind:this={menuList}
|
||||
/>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.font-input {
|
||||
position: relative;
|
||||
|
||||
.dropdown-box {
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
background: var(--color-1-nearblack);
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
|
||||
.dropdown-label {
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin: 6px 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
background: var(--color-6-lowergray);
|
||||
|
||||
span {
|
||||
color: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
span {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list .floating-menu-container .floating-menu-content {
|
||||
max-height: 400px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { currentDraggingElement } from "@/io-managers/drag";
|
||||
|
||||
import type { LayerType, LayerTypeData } from "@/wasm-communication/messages";
|
||||
import { layerTypeData } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
// emits: ["update:value"],
|
||||
const dispatch = createEventDispatcher<{ value: string | undefined }>();
|
||||
|
||||
export let value: string | undefined = undefined;
|
||||
export let layerName: string | undefined = undefined;
|
||||
export let layerType: LayerType | undefined = undefined;
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
let hoveringDrop = false;
|
||||
|
||||
$: droppable = hoveringDrop && Boolean(currentDraggingElement());
|
||||
|
||||
function dragOver(e: DragEvent): void {
|
||||
hoveringDrop = true;
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function drop(e: DragEvent): void {
|
||||
hoveringDrop = false;
|
||||
|
||||
const element = currentDraggingElement();
|
||||
const layerPath = element?.getAttribute("data-layer") || undefined;
|
||||
|
||||
if (layerPath) {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch("value", layerPath);
|
||||
}
|
||||
}
|
||||
|
||||
function getLayerTypeData(layerType: LayerType): LayerTypeData {
|
||||
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow
|
||||
class="layer-reference-input"
|
||||
classes={{ disabled, droppable, "sharp-right-corners": sharpRightCorners }}
|
||||
{tooltip}
|
||||
on:dragover={(e) => !disabled && dragOver(e)}
|
||||
on:dragleave={() => !disabled && (hoveringDrop = false)}
|
||||
on:drop={(e) => !disabled && drop(e)}
|
||||
>
|
||||
{#if value === undefined || droppable}
|
||||
<LayoutRow class="drop-zone" />
|
||||
<TextLabel italic={true}>{droppable ? "Drop" : "Drag"} Layer Here</TextLabel>
|
||||
{:else}
|
||||
{#if layerName !== undefined && layerType}
|
||||
<IconLabel icon={getLayerTypeData(layerType).icon} class="layer-icon" />
|
||||
<TextLabel italic={layerName === ""} class="layer-name">{layerName || getLayerTypeData(layerType).name}</TextLabel>
|
||||
{:else}
|
||||
<TextLabel bold={true} italic={true} class="missing">Layer Missing</TextLabel>
|
||||
{/if}
|
||||
<IconButton icon="CloseX" size={16} {disabled} action={() => dispatch("value", undefined)} />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.layer-reference-input {
|
||||
position: relative;
|
||||
flex: 1 0 auto;
|
||||
height: 24px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-1-nearblack);
|
||||
|
||||
.drop-zone {
|
||||
pointer-events: none;
|
||||
border: 1px dashed var(--color-5-dullgray);
|
||||
border-radius: 1px;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
&.droppable .drop-zone {
|
||||
border: 1px dashed var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
margin: 4px 8px;
|
||||
|
||||
+ .text-label {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
line-height: 18px;
|
||||
padding: 3px calc(8px + 2px);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
&.missing {
|
||||
// TODO: Define this as a permanent color palette choice (search the project for all uses of this hex code)
|
||||
color: #d6536e;
|
||||
}
|
||||
|
||||
&.layer-name {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
margin: 4px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-2-mildblack);
|
||||
|
||||
.drop-zone {
|
||||
border: 1px dashed var(--color-4-dimgray);
|
||||
}
|
||||
|
||||
.text-label {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.icon-label svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import { type KeyRaw, type LayoutKeysGroup, type MenuBarEntry, type MenuListEntry, UpdateMenuBarLayout } from "@/wasm-communication/messages";
|
||||
|
||||
import MenuList from "@/components/floating-menus/MenuList.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
// TODO: Apparently, Safari does not support the Keyboard.lock() API but does relax its authority over certain keyboard shortcuts in fullscreen mode, which we should take advantage of
|
||||
const accelKey = platformIsMac() ? "Command" : "Control";
|
||||
const LOCK_REQUIRING_SHORTCUTS: KeyRaw[][] = [
|
||||
[accelKey, "KeyW"],
|
||||
[accelKey, "KeyN"],
|
||||
[accelKey, "Shift", "KeyN"],
|
||||
[accelKey, "KeyT"],
|
||||
[accelKey, "Shift", "KeyT"],
|
||||
];
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
let self: HTMLDivElement;
|
||||
let entries: MenuListEntry[] = [];
|
||||
|
||||
function clickEntry(menuListEntry: MenuListEntry, e: MouseEvent) {
|
||||
// If there's no menu to open, trigger the action but don't try to open its non-existant children
|
||||
if (!menuListEntry.children || menuListEntry.children.length === 0) {
|
||||
if (menuListEntry.action && !menuListEntry.disabled) menuListEntry.action();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the target so that keyboard inputs are sent to the dropdown
|
||||
(e.target as HTMLElement | undefined)?.focus();
|
||||
|
||||
if (menuListEntry.ref) menuListEntry.ref.isOpen = true;
|
||||
else throw new Error("The menu bar floating menu has no associated ref");
|
||||
}
|
||||
|
||||
function unFocusEntry(menuListEntry: MenuListEntry, e: FocusEvent) {
|
||||
const blurTarget = (e.target as HTMLElement | undefined)?.closest("[data-menu-bar-input]");
|
||||
if (blurTarget !== self && menuListEntry.ref) menuListEntry.ref.isOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => {
|
||||
const arraysEqual = (a: KeyRaw[], b: KeyRaw[]): boolean => a.length === b.length && a.every((aValue, i) => aValue === b[i]);
|
||||
const shortcutRequiresLock = (shortcut: LayoutKeysGroup): boolean => {
|
||||
const shortcutKeys = shortcut.map((keyWithLabel) => keyWithLabel.key);
|
||||
|
||||
// If this shortcut matches any of the browser-reserved shortcuts
|
||||
return LOCK_REQUIRING_SHORTCUTS.some((lockKeyCombo) => arraysEqual(shortcutKeys, lockKeyCombo));
|
||||
};
|
||||
|
||||
const menuBarEntryToMenuListEntry = (entry: MenuBarEntry): MenuListEntry => ({
|
||||
// From `MenuEntryCommon`
|
||||
...entry,
|
||||
|
||||
// Shared names with fields that need to be converted from the type used in `MenuBarEntry` to that of `MenuListEntry`
|
||||
action: (): void => editor.instance.updateLayout(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined),
|
||||
children: entry.children ? entry.children.map((entries) => entries.map((entry) => menuBarEntryToMenuListEntry(entry))) : undefined,
|
||||
|
||||
// New fields in `MenuListEntry`
|
||||
shortcutRequiresLock: entry.shortcut ? shortcutRequiresLock(entry.shortcut.keys) : undefined,
|
||||
value: undefined,
|
||||
disabled: undefined,
|
||||
font: undefined,
|
||||
ref: undefined,
|
||||
});
|
||||
|
||||
entries = updateMenuBarLayout.layout.map(menuBarEntryToMenuListEntry);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="menu-bar-input" bind:this={self} data-menu-bar-input>
|
||||
{#each entries as entry, index (index)}
|
||||
<div class="entry-container">
|
||||
<div
|
||||
on:click={(e) => clickEntry(entry, e)}
|
||||
on:blur={(e) => unFocusEntry(entry, e)}
|
||||
on:keydown={(e) => entry.ref?.keydown(e, false)}
|
||||
class="entry"
|
||||
class:open={entry.ref?.isOpen}
|
||||
tabindex="0"
|
||||
data-floating-menu-spawner={entry.children && entry.children.length > 0 ? "" : "no-hover-transfer"}
|
||||
>
|
||||
{#if entry.icon}
|
||||
<IconLabel icon={entry.icon} />
|
||||
{/if}
|
||||
{#if entry.label}
|
||||
<TextLabel>{entry.label}</TextLabel>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.children && entry.children.length > 0}
|
||||
<MenuList open={entry.ref?.menuIsOpen() || false} entries={entry.children || []} direction="Bottom" minWidth={240} drawIcon={true} bind:this={entry.ref} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.menu-bar-input {
|
||||
display: flex;
|
||||
|
||||
.entry-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
background: none;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,485 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages";
|
||||
|
||||
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
|
||||
|
||||
// emits: ["update:value"],
|
||||
const dispatch = createEventDispatcher<{ value: number | undefined }>();
|
||||
|
||||
// Label
|
||||
export let label: string | undefined = undefined;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
// Disabled
|
||||
export let disabled = false;
|
||||
|
||||
// Value
|
||||
export let value: number | undefined = undefined; // When not provided, a dash is displayed
|
||||
export let min: number | undefined = undefined;
|
||||
export let max: number | undefined = undefined;
|
||||
export let isInteger = false;
|
||||
|
||||
// Number presentation
|
||||
export let displayDecimalPlaces = 3;
|
||||
export let unit = "";
|
||||
export let unitIsHiddenWhenEditing = true;
|
||||
|
||||
// Mode behavior
|
||||
// "Increment" shows arrows and allows dragging left/right to change the value.
|
||||
// "Range" shows a range slider between some minimum and maximum value.
|
||||
export let mode: NumberInputMode = "Increment";
|
||||
// When `mode` is "Increment", `step` is the multiplier or addend used with `incrementBehavior`.
|
||||
// When `mode` is "Range", `step` is the range slider's snapping increment if `isInteger` is `true`.
|
||||
export let step = 1;
|
||||
// `incrementBehavior` is only applicable with a `mode` of "Increment".
|
||||
// "Add"/"Multiply": The value is added or multiplied by `step`.
|
||||
// "None": the increment arrows are not shown.
|
||||
// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
|
||||
export let incrementBehavior: NumberInputIncrementBehavior = "Add";
|
||||
// `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range".
|
||||
// They set the lower and upper values of the slider to drag between.
|
||||
export let rangeMin = 0;
|
||||
export let rangeMax = 1;
|
||||
|
||||
// Styling
|
||||
export let minWidth = 0;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
// Callbacks
|
||||
export let incrementCallbackIncrease: (() => void) | undefined = undefined;
|
||||
export let incrementCallbackDecrease: (() => void) | undefined = undefined;
|
||||
|
||||
let fieldInput: FieldInput;
|
||||
let text = displayText(value);
|
||||
let editing = false;
|
||||
// Stays in sync with a binding to the actual input range slider element.
|
||||
let rangeSliderValue = value !== undefined ? value : 0;
|
||||
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
|
||||
// This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown.
|
||||
let rangeSliderValueAsRendered = value !== undefined ? value : 0;
|
||||
// "default": no interaction is happening.
|
||||
// "mousedown": the user has pressed down the mouse and might next decide to either drag left/right or release without dragging.
|
||||
// "dragging": the user is dragging the slider left/right.
|
||||
let rangeSliderClickDragState: "default" | "mousedown" | "dragging" = "default";
|
||||
|
||||
$: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
|
||||
$: watchValue(value);
|
||||
|
||||
// Called only when `value` is changed from outside this component (with v-model)
|
||||
function watchValue(value: number | undefined) {
|
||||
// Draw a dash if the value is undefined
|
||||
if (value === undefined) {
|
||||
text = "-";
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the range slider with the new value
|
||||
rangeSliderValue = value;
|
||||
rangeSliderValueAsRendered = value;
|
||||
|
||||
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
|
||||
let sanitized = value;
|
||||
if (typeof min === "number") sanitized = Math.max(sanitized, min);
|
||||
if (typeof max === "number") sanitized = Math.min(sanitized, max);
|
||||
|
||||
text = displayText(sanitized);
|
||||
}
|
||||
|
||||
function sliderInput() {
|
||||
// Keep only 4 digits after the decimal point
|
||||
const ROUNDING_EXPONENT = 4;
|
||||
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
|
||||
const roundedValue = Math.round(rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE;
|
||||
|
||||
// Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox
|
||||
if (value !== undefined && Math.abs(value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The first event upon mousedown means we transition to a "mousedown" state
|
||||
if (rangeSliderClickDragState === "default") {
|
||||
rangeSliderClickDragState = "mousedown";
|
||||
|
||||
// Exit early because we don't want to use the value set by where on the track the user pressed
|
||||
return;
|
||||
}
|
||||
|
||||
// The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider
|
||||
if (rangeSliderClickDragState === "mousedown") {
|
||||
rangeSliderClickDragState = "dragging";
|
||||
}
|
||||
|
||||
// If we're in a dragging state, we want to use the new slider value
|
||||
rangeSliderValueAsRendered = roundedValue;
|
||||
updateValue(roundedValue);
|
||||
}
|
||||
|
||||
function sliderPointerDown() {
|
||||
// We want to render the fake slider thumb at the old position, which is still the number held by `value`
|
||||
rangeSliderValueAsRendered = value || 0;
|
||||
|
||||
// Because an `input` event is fired right before or after this (depending on browser), that first
|
||||
// invocation will transition the state machine to `mousedown`. That's why we don't do it here.
|
||||
}
|
||||
|
||||
function sliderPointerUp() {
|
||||
// User clicked but didn't drag, so we focus the text input element
|
||||
if (rangeSliderClickDragState === "mousedown") {
|
||||
const inputElement = fieldInput.querySelector("[data-input-element]") as HTMLInputElement | undefined;
|
||||
if (!inputElement) return;
|
||||
|
||||
// Set the slider position back to the original position to undo the user moving it
|
||||
rangeSliderValue = rangeSliderValueAsRendered;
|
||||
|
||||
// Begin editing the number text field
|
||||
inputElement.focus();
|
||||
}
|
||||
|
||||
// Releasing the mouse means we can reset the state machine
|
||||
rangeSliderClickDragState = "default";
|
||||
}
|
||||
|
||||
function onTextFocused() {
|
||||
if (value === undefined) text = "";
|
||||
else if (unitIsHiddenWhenEditing) text = `${value}`;
|
||||
else text = `${value}${unPluralize(unit, value)}`;
|
||||
|
||||
editing = true;
|
||||
|
||||
fieldInput.selectAllText(text);
|
||||
}
|
||||
|
||||
// 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 unfocused (with the `blur` event binding)
|
||||
function onTextChanged() {
|
||||
// The `unFocus()` call at the bottom of this function and in `onCancelTextChange()` causes this function to be run again, so this check skips a second run
|
||||
if (!editing) return;
|
||||
|
||||
const parsed = parseFloat(text);
|
||||
const newValue = Number.isNaN(parsed) ? undefined : parsed;
|
||||
|
||||
updateValue(newValue);
|
||||
|
||||
editing = false;
|
||||
|
||||
fieldInput.unFocus();
|
||||
}
|
||||
|
||||
function onCancelTextChange() {
|
||||
updateValue(undefined);
|
||||
|
||||
editing = false;
|
||||
|
||||
fieldInput.unFocus();
|
||||
}
|
||||
|
||||
function onIncrement(direction: "Decrease" | "Increase") {
|
||||
if (value === undefined) return;
|
||||
|
||||
const actions: Record<NumberInputIncrementBehavior, () => void> = {
|
||||
Add: () => {
|
||||
const directionAddend = direction === "Increase" ? step : -step;
|
||||
updateValue(value !== undefined ? value + directionAddend : undefined);
|
||||
},
|
||||
Multiply: () => {
|
||||
const directionMultiplier = direction === "Increase" ? step : 1 / step;
|
||||
updateValue(value !== undefined ? value * directionMultiplier : undefined);
|
||||
},
|
||||
Callback: () => {
|
||||
if (direction === "Increase") incrementCallbackIncrease?.();
|
||||
if (direction === "Decrease") incrementCallbackDecrease?.();
|
||||
},
|
||||
None: () => {},
|
||||
};
|
||||
const action = actions[incrementBehavior];
|
||||
action();
|
||||
}
|
||||
|
||||
function updateValue(newValue: number | undefined) {
|
||||
const nowValid = value !== undefined && isInteger ? Math.round(value) : value;
|
||||
let cleaned = newValue !== undefined ? newValue : nowValid;
|
||||
|
||||
if (typeof min === "number" && !Number.isNaN(min) && cleaned !== undefined) cleaned = Math.max(cleaned, min);
|
||||
if (typeof max === "number" && !Number.isNaN(max) && cleaned !== undefined) cleaned = Math.min(cleaned, max);
|
||||
|
||||
// Required as the call to update:value can, not change the value
|
||||
text = displayText(value);
|
||||
|
||||
if (newValue !== undefined) dispatch("value", cleaned);
|
||||
}
|
||||
|
||||
function displayText(value: number | undefined): string {
|
||||
if (value === undefined) return "-";
|
||||
|
||||
// Find the amount of digits on the left side of the decimal
|
||||
// 10.25 == 2
|
||||
// 1.23 == 1
|
||||
// 0.23 == 0 (Reason for the slightly more complicated code)
|
||||
const absValueInt = Math.floor(Math.abs(value));
|
||||
const leftSideDigits = absValueInt === 0 ? 0 : absValueInt.toString().length;
|
||||
const roundingPower = 10 ** Math.max(displayDecimalPlaces - leftSideDigits, 0);
|
||||
|
||||
const displayValue = Math.round(value * roundingPower) / roundingPower;
|
||||
|
||||
return `${displayValue}${unPluralize(unit, value)}`;
|
||||
}
|
||||
|
||||
function unPluralize(unit: string, value: number): string {
|
||||
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
|
||||
return unit;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class={`number-input ${mode.toLocaleLowerCase()}`}
|
||||
value={text}
|
||||
on:value={({ detail }) => (text = detail)}
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:cancelTextChange={onCancelTextChange}
|
||||
{label}
|
||||
{disabled}
|
||||
{tooltip}
|
||||
{sharpRightCorners}
|
||||
spellcheck={false}
|
||||
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined, "--progress-factor": (rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin) }}
|
||||
bind:this={fieldInput}
|
||||
>
|
||||
{#if value !== undefined && mode === "Increment" && incrementBehavior !== "None"}
|
||||
<button class="arrow left" on:click={() => onIncrement("Decrease")} tabindex="-1" />
|
||||
<button class="arrow right" on:click={() => onIncrement("Increase")} tabindex="-1" />
|
||||
{/if}
|
||||
{#if mode === "Range" && value !== undefined}
|
||||
<input
|
||||
type="range"
|
||||
class="slider"
|
||||
class:hidden={rangeSliderClickDragState === "mousedown"}
|
||||
bind:value={rangeSliderValue}
|
||||
min={rangeMin}
|
||||
max={rangeMax}
|
||||
step={sliderStepValue}
|
||||
{disabled}
|
||||
on:input={sliderInput}
|
||||
on:pointerdown={sliderPointerDown}
|
||||
on:pointerup={sliderPointerUp}
|
||||
tabindex="-1"
|
||||
/>
|
||||
{/if}
|
||||
{#if value !== undefined}
|
||||
{#if value !== undefined && rangeSliderClickDragState === "mousedown"}
|
||||
<div class="fake-slider-thumb" />
|
||||
{/if}
|
||||
<div class="slider-progress" />
|
||||
{/if}
|
||||
</FieldInput>
|
||||
|
||||
<style lang="scss" global>
|
||||
.number-input {
|
||||
input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.increment {
|
||||
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
|
||||
label {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
input[type="text"]:not(:focus).has-label {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
// Hide the increment arrows when entering text, disabled, or not hovered
|
||||
input[type="text"]:focus ~ .arrow,
|
||||
&.disabled .arrow,
|
||||
&:not(:hover) .arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Style the increment arrows
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding: 9px 0;
|
||||
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: "";
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 3px 0 3px 3px;
|
||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||
}
|
||||
}
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
padding-left: 6px;
|
||||
padding-right: 7px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 3px 3px 3px 0;
|
||||
border-color: transparent var(--color-e-nearwhite) transparent transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.range {
|
||||
position: relative;
|
||||
|
||||
input[type="text"],
|
||||
label {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
input[type="text"]:focus ~ .slider,
|
||||
input[type="text"]:focus ~ .fake-slider-thumb,
|
||||
input[type="text"]:focus ~ .slider-progress {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
|
||||
appearance: none;
|
||||
background: none;
|
||||
cursor: default;
|
||||
// Except when disabled, the range slider goes above the label and input so it's interactable.
|
||||
// Then we use the blend mode to make it appear behind which works since the text is almost white and background almost black.
|
||||
// When disabled, the blend mode trick doesn't work with the grayer colors. But we don't need it to be interactable, so it can actually go behind properly.
|
||||
z-index: 2;
|
||||
mix-blend-mode: screen;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Chromium and Safari
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; // Required until Safari 15.4 (Graphite supports 15.0+)
|
||||
appearance: none;
|
||||
border-radius: 2px;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
||||
}
|
||||
|
||||
&:hover::-webkit-slider-thumb {
|
||||
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
mix-blend-mode: normal;
|
||||
z-index: 0;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
background: var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox
|
||||
&::-moz-range-thumb {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
||||
}
|
||||
|
||||
&:hover::-moz-range-thumb {
|
||||
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||
}
|
||||
|
||||
&:hover ~ .slider-progress::before {
|
||||
background: var(--color-3-darkgray);
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// This fake slider thumb stays in the location of the real thumb while we have to hide the real slider between mousedown and mouseup or mousemove.
|
||||
// That's because the range input element moves to the pressed location immediately upon mousedown, but we don't want to show that yet.
|
||||
// Instead, we want to wait until the user does something:
|
||||
// Releasing the mouse means we reset the slider to its previous location, thus canceling the slider move. In that case, we focus the text entry.
|
||||
// Moving the mouse left/right means we have begun dragging, so then we hide this fake one and continue showing the actual drag of the real slider.
|
||||
.fake-slider-thumb {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 2px;
|
||||
margin-left: -2px;
|
||||
left: calc(var(--progress-factor) * 100%);
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||
}
|
||||
}
|
||||
|
||||
.slider-progress {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(var(--progress-factor) * 100% - 2px);
|
||||
height: 100%;
|
||||
background: var(--color-2-mildblack);
|
||||
border-radius: 1px 0 0 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.svelte";
|
||||
|
||||
// emits: ["update:checked"],
|
||||
const dispatch = createEventDispatcher<{ checked: boolean }>();
|
||||
|
||||
export let checked: boolean;
|
||||
export let disabled = false;
|
||||
export let icon: IconName = "Checkmark";
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
let checkboxInput: CheckboxInput;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="optional-input" classes={{ disabled }}>
|
||||
<CheckboxInput {checked} on:checked {disabled} {icon} {tooltip} bind:this={checkboxInput} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.optional-input {
|
||||
flex-grow: 0;
|
||||
|
||||
label {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-5-dullgray);
|
||||
border-radius: 2px 0 0 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&.disabled label {
|
||||
border: 1px solid var(--color-4-dimgray);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { type RadioEntries, type RadioEntryData } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
// emits: ["update:selectedIndex"],
|
||||
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
|
||||
|
||||
export let entries: RadioEntries;
|
||||
export let selectedIndex: number;
|
||||
export let disabled = false;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
function handleEntryClick(radioEntryData: RadioEntryData) {
|
||||
const index = entries.indexOf(radioEntryData);
|
||||
dispatch("selectedIndex", index);
|
||||
|
||||
radioEntryData.action?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="radio-input" classes={{ disabled }}>
|
||||
{#each entries as entry, index (index)}
|
||||
<button
|
||||
class:active={index === selectedIndex}
|
||||
class:disabled
|
||||
class:sharp-right-corners={index === entries.length - 1 && sharpRightCorners}
|
||||
on:click={() => handleEntryClick(entry)}
|
||||
title={entry.tooltip}
|
||||
tabindex={index === selectedIndex ? -1 : 0}
|
||||
{disabled}
|
||||
>
|
||||
{#if entry.icon}
|
||||
<IconLabel icon={entry.icon} />
|
||||
{/if}
|
||||
{#if entry.label}
|
||||
<TextLabel>{entry.label}</TextLabel>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.radio-input {
|
||||
button {
|
||||
background: var(--color-5-dullgray);
|
||||
fill: var(--color-e-nearwhite);
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-e-nearwhite);
|
||||
color: var(--color-2-mildblack);
|
||||
|
||||
svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-4-dimgray);
|
||||
color: var(--color-8-uppergray);
|
||||
|
||||
svg {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-8-uppergray);
|
||||
color: var(--color-2-mildblack);
|
||||
|
||||
svg {
|
||||
fill: var(--color-2-mildblack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& + button {
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
&.combined-before button:first-of-type,
|
||||
&.combined-after button:last-of-type {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import { type Color } from "@/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import { Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
export let primary: Color;
|
||||
export let secondary: Color;
|
||||
|
||||
let primaryOpen = false;
|
||||
let secondaryOpen = false;
|
||||
|
||||
function clickPrimarySwatch() {
|
||||
primaryOpen = true;
|
||||
secondaryOpen = false;
|
||||
}
|
||||
|
||||
function clickSecondarySwatch() {
|
||||
primaryOpen = false;
|
||||
secondaryOpen = true;
|
||||
}
|
||||
|
||||
function primaryColorChanged(color: Color) {
|
||||
editor.instance.updatePrimaryColor(color.red, color.green, color.blue, color.alpha);
|
||||
}
|
||||
|
||||
function secondaryColorChanged(color: Color) {
|
||||
editor.instance.updateSecondaryColor(color.red, color.green, color.blue, color.alpha);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="swatch-pair">
|
||||
<LayoutRow class="primary swatch">
|
||||
<button on:click={clickPrimarySwatch} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||
<ColorPicker open={primaryOpen} on:open={({ detail }) => (primaryOpen = detail)} color={primary} on:color={({ detail }) => primaryColorChanged(detail)} direction="Right" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="secondary swatch">
|
||||
<button on:click={clickSecondarySwatch} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||
<ColorPicker open={secondaryOpen} on:open={({ detail }) => (secondaryOpen = detail)} color={secondary} on:color={({ detail }) => secondaryColorChanged(detail)} direction="Right" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.swatch-pair {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.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-5-dullgray) solid;
|
||||
box-shadow: 0 0 0 2px var(--color-3-darkgray);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-menu {
|
||||
top: 50%;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
margin-bottom: -8px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
|
||||
|
||||
// emits: ["update:value", "commitText"],
|
||||
const dispatch = createEventDispatcher<{ value: string; commitText: string }>();
|
||||
|
||||
export let value: string;
|
||||
export let label: string | undefined = undefined;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let disabled = false;
|
||||
|
||||
let fieldInput: FieldInput;
|
||||
let editing = false;
|
||||
let inputValue = value;
|
||||
|
||||
$: dispatch("value", inputValue);
|
||||
|
||||
function onTextFocused() {
|
||||
editing = true;
|
||||
}
|
||||
|
||||
// Called only when `value` is changed from the <textarea> element via user input and committed, either
|
||||
// via the `change` event or when the <input> element is unfocused (with the `blur` event binding)
|
||||
function onTextChanged() {
|
||||
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
|
||||
if (!editing) return;
|
||||
|
||||
onCancelTextChange();
|
||||
|
||||
// TODO: Find a less hacky way to do this
|
||||
dispatch("commitText", fieldInput.getInputElementValue());
|
||||
|
||||
// Required if value is not changed by the parent component upon update:value event
|
||||
fieldInput.setInputElementValue(value);
|
||||
}
|
||||
|
||||
function onCancelTextChange() {
|
||||
editing = false;
|
||||
|
||||
fieldInput.unFocus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class="text-area-input"
|
||||
classes={{
|
||||
// TODO: Svelte: check if this should be based on `Boolean(label)` or `label !== ""`
|
||||
"has-label": Boolean(label),
|
||||
}}
|
||||
on:value={({ detail }) => (inputValue = detail)}
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:cancelTextChange={onCancelTextChange}
|
||||
textarea={true}
|
||||
spellcheck={true}
|
||||
{label}
|
||||
{disabled}
|
||||
{tooltip}
|
||||
value={inputValue}
|
||||
bind:this={fieldInput}
|
||||
/>
|
||||
|
||||
<style lang="scss" global>
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
|
||||
|
||||
// emits: ["update:value", "commitText"],
|
||||
const dispatch = createEventDispatcher<{ value: string; commitText: string }>();
|
||||
|
||||
// Label
|
||||
export let label: string | undefined = undefined;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
// Disabled
|
||||
export let disabled = false;
|
||||
// Value
|
||||
export let value: string;
|
||||
// Styling
|
||||
export let centered = false;
|
||||
export let minWidth = 0;
|
||||
export let sharpRightCorners = false;
|
||||
|
||||
let fieldInput: FieldInput;
|
||||
let editing = false;
|
||||
let text = value;
|
||||
|
||||
$: dispatch("value", text);
|
||||
|
||||
function onTextFocused() {
|
||||
editing = true;
|
||||
|
||||
fieldInput.selectAllText(text);
|
||||
}
|
||||
|
||||
// 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 unfocused (with the `blur` event binding)
|
||||
function onTextChanged() {
|
||||
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
|
||||
if (!editing) return;
|
||||
|
||||
onCancelTextChange();
|
||||
|
||||
// TODO: Find a less hacky way to do this
|
||||
dispatch("commitText", fieldInput.getInputElementValue());
|
||||
|
||||
// Required if value is not changed by the parent component upon update:value event
|
||||
fieldInput.setInputElementValue(value);
|
||||
}
|
||||
|
||||
function onCancelTextChange() {
|
||||
editing = false;
|
||||
|
||||
fieldInput.unFocus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<FieldInput
|
||||
class="text-input"
|
||||
classes={{ centered }}
|
||||
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
|
||||
value={text}
|
||||
on:value={({ detail }) => (text = detail)}
|
||||
on:textFocused={onTextFocused}
|
||||
on:textChanged={onTextChanged}
|
||||
on:cancelTextChange={onCancelTextChange}
|
||||
spellcheck={true}
|
||||
{label}
|
||||
{disabled}
|
||||
{tooltip}
|
||||
{placeholder}
|
||||
{sharpRightCorners}
|
||||
bind:this={fieldInput}
|
||||
/>
|
||||
|
||||
<style lang="scss" global>
|
||||
.text-input {
|
||||
input {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.centered {
|
||||
input:not(:focus) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { type IconName, ICONS, ICON_SVG_STRINGS } from "@/utility-functions/icons";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
// TODO: Svelte: fix icon imports
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
export let icon: IconName;
|
||||
export let disabled = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
$: iconSizeClass = ((icon: IconName) => {
|
||||
return `size-${ICONS[icon].size}`;
|
||||
})(icon);
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<LayoutRow class={`icon-label ${iconSizeClass} ${className} ${extraClasses}`.trim()} classes={{ disabled }} {tooltip}>
|
||||
{@html ICON_SVG_STRINGS[icon]}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.icon-label {
|
||||
flex: 0 0 auto;
|
||||
fill: var(--color-e-nearwhite);
|
||||
|
||||
&.disabled {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.size-12 {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.size-16 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.size-24 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { type SeparatorDirection, type SeparatorType } from "@/wasm-communication/messages";
|
||||
|
||||
export let direction: SeparatorDirection = "Horizontal";
|
||||
export let type: SeparatorType = "Unrelated";
|
||||
</script>
|
||||
|
||||
<div class={`separator ${direction.toLowerCase()} ${type.toLowerCase()}`}>
|
||||
{#if ["Section", "List"].includes(type)}
|
||||
<div />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.separator {
|
||||
&.vertical {
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.related {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&.unrelated {
|
||||
height: 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 {
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.related {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&.unrelated {
|
||||
width: 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>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
let className = "";
|
||||
export { className as class };
|
||||
export let classes: Record<string, boolean> = {};
|
||||
let styleName = "";
|
||||
export { styleName as style };
|
||||
export let styles: Record<string, string | number | undefined> = {};
|
||||
export let disabled = false;
|
||||
export let bold = false;
|
||||
export let italic = false;
|
||||
export let tableAlign = false;
|
||||
export let minWidth = 0;
|
||||
export let multiline = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
|
||||
.join(" ");
|
||||
$: extraStyles = Object.entries(styles)
|
||||
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
|
||||
.join(" ");
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={`text-label ${className} ${extraClasses}`.trim()}
|
||||
class:disabled
|
||||
class:bold
|
||||
class:italic
|
||||
class:multiline
|
||||
class:table-align={tableAlign}
|
||||
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
|
||||
style={`${styleName} ${extraStyles}`.trim() || undefined}
|
||||
title={tooltip}
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<style lang="scss" global>
|
||||
.text-label {
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
// Force Safari to not draw a text cursor, even though this element has `user-select: none`
|
||||
cursor: default;
|
||||
|
||||
&.disabled {
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&.multiline {
|
||||
white-space: pre-wrap;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
&.table-align {
|
||||
flex: 0 0 30%;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import { type IconName } from "@/utility-functions/icons";
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import { type KeyRaw, type LayoutKeysGroup, type Key, type MouseMotion } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import Separator from "@/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { type FullscreenState } from "@/state-providers/fullscreen";
|
||||
|
||||
type LabelData = { label?: string; icon?: IconName; width: string };
|
||||
|
||||
// Keys that become icons if they are listed here with their units of width
|
||||
const ICON_WIDTHS_MAC = {
|
||||
Shift: 2,
|
||||
Control: 2,
|
||||
Option: 2,
|
||||
Command: 2,
|
||||
};
|
||||
const ICON_WIDTHS = {
|
||||
ArrowUp: 1,
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: 1,
|
||||
Backspace: 2,
|
||||
Enter: 2,
|
||||
Tab: 2,
|
||||
Space: 3,
|
||||
...(platformIsMac() ? ICON_WIDTHS_MAC : {}),
|
||||
};
|
||||
|
||||
const fullscreen = getContext<FullscreenState>("fullscreen");
|
||||
|
||||
export let keysWithLabelsGroups: LayoutKeysGroup[] = [];
|
||||
export let mouseMotion: MouseMotion | undefined = undefined;
|
||||
export let requiresLock = false;
|
||||
|
||||
$: keyboardLockInfoMessage = watchKeyboardLockInfoMessage(fullscreen.keyboardLockApiSupported);
|
||||
$: displayKeyboardLockNotice = requiresLock && !$fullscreen.keyboardLocked;
|
||||
|
||||
function watchKeyboardLockInfoMessage(keyboardLockApiSupported: boolean): string {
|
||||
const RESERVED = "This hotkey is reserved by the browser. ";
|
||||
const USE_FULLSCREEN = "It is made available in fullscreen mode.";
|
||||
const USE_SECURE_CTX = "It is made available in fullscreen mode when this website is served from a secure context (https or localhost).";
|
||||
const SWITCH_BROWSER = "Use a Chromium-based browser (like Chrome or Edge) in fullscreen mode to directly use the shortcut.";
|
||||
|
||||
if (keyboardLockApiSupported) return `${RESERVED} ${USE_FULLSCREEN}`;
|
||||
if (!("chrome" in window)) return `${RESERVED} ${SWITCH_BROWSER}`;
|
||||
if (!window.isSecureContext) return `${RESERVED} ${USE_SECURE_CTX}`;
|
||||
return RESERVED;
|
||||
}
|
||||
|
||||
function keyTextOrIconList(keyGroup: LayoutKeysGroup): LabelData[] {
|
||||
return keyGroup.map((key) => keyTextOrIcon(key));
|
||||
}
|
||||
|
||||
function keyTextOrIcon(keyWithLabel: Key): LabelData {
|
||||
// `key` is the name of the `Key` enum in Rust, while `label` is the localized string to display (if it doesn't become an icon)
|
||||
let key = keyWithLabel.key;
|
||||
const label = keyWithLabel.label;
|
||||
|
||||
// Replace Alt and Accel keys with their Mac-specific equivalents
|
||||
if (platformIsMac()) {
|
||||
if (key === "Alt") key = "Option";
|
||||
if (key === "Accel") key = "Command";
|
||||
}
|
||||
|
||||
// Either display an icon...
|
||||
// @ts-expect-error We want undefined if it isn't in the object
|
||||
const iconWidth: number | undefined = ICON_WIDTHS[key];
|
||||
const icon = iconWidth !== undefined && iconWidth > 0 && (keyboardHintIcon(key) || false);
|
||||
if (icon) return { icon, width: `width-${iconWidth}` };
|
||||
|
||||
// ...or display text
|
||||
return { label, width: `width-${label.length}` };
|
||||
}
|
||||
|
||||
function mouseHintIcon(input?: MouseMotion): IconName {
|
||||
return `MouseHint${input}` as IconName;
|
||||
}
|
||||
|
||||
function keyboardHintIcon(input: KeyRaw): IconName | undefined {
|
||||
switch (input) {
|
||||
case "ArrowDown":
|
||||
return "KeyboardArrowDown";
|
||||
case "ArrowLeft":
|
||||
return "KeyboardArrowLeft";
|
||||
case "ArrowRight":
|
||||
return "KeyboardArrowRight";
|
||||
case "ArrowUp":
|
||||
return "KeyboardArrowUp";
|
||||
case "Backspace":
|
||||
return "KeyboardBackspace";
|
||||
case "Command":
|
||||
return "KeyboardCommand";
|
||||
case "Control":
|
||||
return "KeyboardControl";
|
||||
case "Enter":
|
||||
return "KeyboardEnter";
|
||||
case "Option":
|
||||
return "KeyboardOption";
|
||||
case "Shift":
|
||||
return "KeyboardShift";
|
||||
case "Space":
|
||||
return "KeyboardSpace";
|
||||
case "Tab":
|
||||
return "KeyboardTab";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if displayKeyboardLockNotice}
|
||||
<IconLabel class="user-input-label keyboard-lock-notice" icon="Info" tooltip={keyboardLockInfoMessage} />
|
||||
{:else}
|
||||
<LayoutRow class="user-input-label">
|
||||
{#each keysWithLabelsGroups as keysWithLabels, groupIndex (groupIndex)}
|
||||
{#if groupIndex > 0}
|
||||
<Separator type="Related" />
|
||||
{/if}
|
||||
{#each keyTextOrIconList(keysWithLabels) as keyInfo, keyIndex (keyIndex)}
|
||||
<div class={`input-key ${keyInfo.width}`}>
|
||||
{#if keyInfo.icon}
|
||||
<IconLabel icon={keyInfo.icon} />
|
||||
{:else if keyInfo.label !== undefined}
|
||||
<TextLabel>{keyInfo.label}</TextLabel>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if mouseMotion}
|
||||
<div class="input-mouse">
|
||||
<IconLabel icon={mouseHintIcon(mouseMotion)} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if $$slots.default}
|
||||
<div class="hint-text">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
|
||||
<style lang="scss" global>
|
||||
.user-input-label {
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
.input-key,
|
||||
.input-mouse {
|
||||
& + .input-key,
|
||||
& + .input-mouse {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-key {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: "Inconsolata", monospace;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
height: 16px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
border-color: var(--color-5-dullgray);
|
||||
color: var(--color-e-nearwhite);
|
||||
|
||||
.text-label {
|
||||
// Firefox renders the text 1px lower than Chrome (tested on Windows) with 16px line-height,
|
||||
// so moving it up 1 pixel by using 15px makes them agree.
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
&.width-1 {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&.width-2 {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
&.width-3 {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
&.width-4 {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
&.width-5 {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
margin: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-mouse {
|
||||
.bright {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.dim {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.floating-menu-content .row > & {
|
||||
.input-key {
|
||||
border-color: var(--color-3-darkgray);
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.input-key .icon-label svg,
|
||||
&.keyboard-lock-notice.keyboard-lock-notice svg,
|
||||
.input-mouse .bright {
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.input-mouse .dim {
|
||||
fill: var(--color-3-darkgray);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu-content .row:hover > & {
|
||||
.input-key {
|
||||
border-color: var(--color-7-middlegray);
|
||||
}
|
||||
|
||||
.input-mouse .dim {
|
||||
fill: var(--color-7-middlegray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts" context="module">
|
||||
export type RulerDirection = "Horizontal" | "Vertical";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const RULER_THICKNESS = 16;
|
||||
const MAJOR_MARK_THICKNESS = 16;
|
||||
const MEDIUM_MARK_THICKNESS = 6;
|
||||
const MINOR_MARK_THICKNESS = 3;
|
||||
|
||||
export let direction: RulerDirection = "Vertical";
|
||||
export let origin: number;
|
||||
export let numberInterval: number;
|
||||
export let majorMarkSpacing: number;
|
||||
export let mediumDivisions: number = 5;
|
||||
export let minorDivisions: number = 2;
|
||||
|
||||
let canvasRuler: HTMLDivElement;
|
||||
let rulerLength = 0;
|
||||
let svgBounds = { width: "0px", height: "0px" };
|
||||
|
||||
$: svgPath = computeSvgPath(direction, origin, majorMarkSpacing, mediumDivisions, minorDivisions, rulerLength);
|
||||
$: svgTexts = computeSvgTexts(direction, origin, majorMarkSpacing, numberInterval, rulerLength);
|
||||
|
||||
function computeSvgPath(direction: RulerDirection, origin: number, majorMarkSpacing: number, mediumDivisions: number, minorDivisions: number, rulerLength: number): string {
|
||||
const isVertical = direction === "Vertical";
|
||||
const lineDirection = isVertical ? "H" : "V";
|
||||
|
||||
const offsetStart = mod(origin, majorMarkSpacing);
|
||||
const shiftedOffsetStart = offsetStart - majorMarkSpacing;
|
||||
|
||||
const divisions = majorMarkSpacing / mediumDivisions / minorDivisions;
|
||||
const majorMarksFrequency = mediumDivisions * minorDivisions;
|
||||
|
||||
let dPathAttribute = "";
|
||||
let i = 0;
|
||||
for (let location = shiftedOffsetStart; location < rulerLength; location += divisions) {
|
||||
let length;
|
||||
if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS;
|
||||
else if (i % minorDivisions === 0) length = MEDIUM_MARK_THICKNESS;
|
||||
else length = MINOR_MARK_THICKNESS;
|
||||
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;
|
||||
}
|
||||
|
||||
function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: number }[] {
|
||||
const isVertical = direction === "Vertical";
|
||||
|
||||
const offsetStart = mod(origin, majorMarkSpacing);
|
||||
const shiftedOffsetStart = offsetStart - majorMarkSpacing;
|
||||
|
||||
const svgTextCoordinates = [];
|
||||
|
||||
let text = (Math.ceil(-origin / majorMarkSpacing) - 1) * numberInterval;
|
||||
|
||||
for (let location = shiftedOffsetStart; location < rulerLength; location += majorMarkSpacing) {
|
||||
const destination = Math.round(location);
|
||||
const x = isVertical ? 9 : destination + 2;
|
||||
const y = isVertical ? destination + 1 : 9;
|
||||
|
||||
let transform = `translate(${x} ${y})`;
|
||||
if (isVertical) transform += " rotate(270)";
|
||||
|
||||
svgTextCoordinates.push({ transform, text });
|
||||
|
||||
text += numberInterval;
|
||||
}
|
||||
|
||||
return svgTextCoordinates;
|
||||
}
|
||||
|
||||
export function resize() {
|
||||
const isVertical = direction === "Vertical";
|
||||
|
||||
const newLength = isVertical ? canvasRuler.clientHeight : canvasRuler.clientWidth;
|
||||
const roundedUp = (Math.floor(newLength / majorMarkSpacing) + 1) * majorMarkSpacing;
|
||||
|
||||
if (roundedUp !== rulerLength) {
|
||||
rulerLength = roundedUp;
|
||||
const thickness = `${RULER_THICKNESS}px`;
|
||||
const length = `${roundedUp}px`;
|
||||
svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness };
|
||||
}
|
||||
}
|
||||
|
||||
// Modulo function that works for negative numbers, unlike the JS `%` operator
|
||||
function mod(n: number, m: number): number {
|
||||
const remainder = n % m;
|
||||
return Math.floor(remainder >= 0 ? remainder : remainder + m);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`canvas-ruler ${direction.toLowerCase()}`} bind:this={canvasRuler}>
|
||||
<svg style:width={svgBounds.width} style:height={svgBounds.height}>
|
||||
<path d={svgPath} />
|
||||
{#each svgTexts as svgText, index (index)}
|
||||
<text transform={svgText.transform}>{svgText.text}</text>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.canvas-ruler {
|
||||
flex: 1 1 100%;
|
||||
background: var(--color-4-dimgray);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&.horizontal {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
width: 16px;
|
||||
|
||||
svg text {
|
||||
text-anchor: end;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
|
||||
path {
|
||||
stroke-width: 1px;
|
||||
stroke: var(--color-7-middlegray);
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 12px;
|
||||
fill: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts" context="module">
|
||||
export type ScrollbarDirection = "Horizontal" | "Vertical";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||
|
||||
// Linear Interpolation
|
||||
const lerp = (x: number, y: number, a: number): number => x * (1 - a) + y * a;
|
||||
|
||||
// Convert the position of the handle (0-1) to the position on the track (0-1).
|
||||
// This includes the 1/2 handle length gap of the possible handle positionson each side so the end of the handle doesn't go off the track.
|
||||
const handleToTrack = (handleLen: number, handlePos: number): number => lerp(handleLen / 2, 1 - handleLen / 2, handlePos);
|
||||
|
||||
const pointerPosition = (direction: ScrollbarDirection, e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
|
||||
|
||||
// emits: { "update:handlePosition": null, pressTrack: (pointerOffset: number) => typeof pointerOffset === "number" }
|
||||
const dispatch = createEventDispatcher<{ handlePosition: number; pressTrack: number }>();
|
||||
|
||||
export let direction: ScrollbarDirection = "Vertical";
|
||||
export let handlePosition: number = 0.5;
|
||||
export let handleLength: number = 0.5;
|
||||
|
||||
let scrollTrack: HTMLDivElement;
|
||||
let dragging = false;
|
||||
let pointerPos = 0;
|
||||
let thumbTop: string | undefined = undefined;
|
||||
let thumbBottom: string | undefined = undefined;
|
||||
let thumbLeft: string | undefined = undefined;
|
||||
let thumbRight: string | undefined = undefined;
|
||||
|
||||
$: start = handleToTrack(handleLength, handlePosition) - handleLength / 2;
|
||||
$: end = 1 - handleToTrack(handleLength, handlePosition) - handleLength / 2;
|
||||
$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`];
|
||||
|
||||
function trackLength(): number | undefined {
|
||||
return direction === "Vertical" ? scrollTrack.clientHeight - handleLength : scrollTrack.clientWidth;
|
||||
}
|
||||
|
||||
function trackOffset(): number | undefined {
|
||||
return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left;
|
||||
}
|
||||
|
||||
function clampHandlePosition(newPos: number) {
|
||||
const clampedPosition = Math.min(Math.max(newPos, 0), 1);
|
||||
dispatch("handlePosition", clampedPosition);
|
||||
}
|
||||
|
||||
function updateHandlePosition(e: PointerEvent) {
|
||||
const length = trackLength();
|
||||
if (length === undefined) return;
|
||||
|
||||
const position = pointerPosition(direction, e);
|
||||
|
||||
clampHandlePosition(handlePosition + (position - pointerPos) / (length * (1 - handleLength)));
|
||||
pointerPos = position;
|
||||
}
|
||||
|
||||
function grabHandle(e: PointerEvent) {
|
||||
if (!dragging) {
|
||||
dragging = true;
|
||||
pointerPos = pointerPosition(direction, e);
|
||||
}
|
||||
}
|
||||
|
||||
function grabArea(e: PointerEvent) {
|
||||
if (!dragging) {
|
||||
const length = trackLength();
|
||||
const offset = trackOffset();
|
||||
if (length === undefined || offset === undefined) return;
|
||||
|
||||
const oldPointer = handleToTrack(handleLength, handlePosition) * length + offset;
|
||||
const pointerPos = pointerPosition(direction, e);
|
||||
dispatch("pressTrack", pointerPos - oldPointer);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUp() {
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
function pointerMove(e: PointerEvent) {
|
||||
if (dragging) updateHandlePosition(e);
|
||||
}
|
||||
|
||||
function changePosition(difference: number) {
|
||||
const length = trackLength();
|
||||
if (length === undefined) return;
|
||||
|
||||
clampHandlePosition(handlePosition + difference / length);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("pointerup", pointerUp);
|
||||
window.addEventListener("pointermove", pointerMove);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener("pointerup", pointerUp);
|
||||
window.removeEventListener("pointermove", pointerMove);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={`persistent-scrollbar ${direction.toLowerCase()}`}>
|
||||
<button class="arrow decrease" on:pointerdown={() => changePosition(-50)} tabindex="-1" />
|
||||
<div class="scroll-track" bind:this={scrollTrack} on:pointerdown={grabArea}>
|
||||
<div class="scroll-thumb" on:pointerdown={grabHandle} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
|
||||
</div>
|
||||
<button class="arrow increase" on:click={() => changePosition(50)} tabindex="-1" />
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.persistent-scrollbar {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
|
||||
.arrow {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: none;
|
||||
border-style: solid;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scroll-track {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
|
||||
.scroll-thumb {
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
background: var(--color-5-dullgray);
|
||||
|
||||
&:hover,
|
||||
&.dragging {
|
||||
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;
|
||||
}
|
||||
&:active {
|
||||
border-color: transparent transparent var(--color-c-brightgray) 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;
|
||||
}
|
||||
&:active {
|
||||
border-color: var(--color-c-brightgray) 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;
|
||||
}
|
||||
&:active {
|
||||
border-color: transparent var(--color-c-brightgray) 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);
|
||||
}
|
||||
&:active {
|
||||
border-color: transparent transparent transparent var(--color-c-brightgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" context="module">
|
||||
export type ApplicationPlatform = "Windows" | "Mac" | "Linux" | "Web";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import StatusBar from "@/components/window/status-bar/StatusBar.svelte";
|
||||
import TitleBar from "@/components/window/title-bar/TitleBar.svelte";
|
||||
import Workspace from "@/components/window/workspace/Workspace.svelte";
|
||||
|
||||
let platform: ApplicationPlatform = "Web";
|
||||
let maximized: true;
|
||||
</script>
|
||||
|
||||
<LayoutCol class="main-window">
|
||||
<TitleBar {platform} {maximized} />
|
||||
|
||||
<Workspace />
|
||||
|
||||
<StatusBar />
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.main-window {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
import { type HintData, type HintInfo, type LayoutKeysGroup, UpdateInputHints } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import Separator from "@/components/widgets/labels/Separator.svelte";
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
let hintData: HintData = [];
|
||||
|
||||
function inputKeysForPlatform(hint: HintInfo): LayoutKeysGroup[] {
|
||||
if (platformIsMac() && hint.keyGroupsMac) return hint.keyGroupsMac;
|
||||
return hint.keyGroups;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor.subscriptions.subscribeJsMessage(UpdateInputHints, (data) => {
|
||||
hintData = data.hintData;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<LayoutRow class="status-bar">
|
||||
<LayoutRow class="hint-groups">
|
||||
{#each hintData as hintGroup, index (hintGroup)}
|
||||
{#if index !== 0}
|
||||
<Separator type={"Section"} />
|
||||
{/if}
|
||||
{#each hintGroup as hint (hint)}
|
||||
{#if hint.plus}
|
||||
<LayoutRow class="plus">+</LayoutRow>
|
||||
{/if}
|
||||
<UserInputLabel mouseMotion={hint.mouse} keysWithLabelsGroups={inputKeysForPlatform(hint)}>{hint.label}</UserInputLabel>
|
||||
{/each}
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.status-bar {
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.hint-groups {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
margin: 0 -4px;
|
||||
overflow: hidden;
|
||||
|
||||
.separator.section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plus {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-input-label {
|
||||
margin: 0 8px;
|
||||
|
||||
& + .user-input-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts" context="module">
|
||||
export type Platform = "Windows" | "Mac" | "Linux" | "Web";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import MenuBarInput from "@/components/widgets/inputs/MenuBarInput.svelte";
|
||||
import WindowButtonsMac from "@/components/window/title-bar/WindowButtonsMac.svelte";
|
||||
import WindowButtonsWeb from "@/components/window/title-bar/WindowButtonsWeb.svelte";
|
||||
import WindowButtonsWindows from "@/components/window/title-bar/WindowButtonsWindows.svelte";
|
||||
import WindowTitle from "@/components/window/title-bar/WindowTitle.svelte";
|
||||
import { type PortfolioState } from "@/state-providers/portfolio";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export let platform: Platform;
|
||||
export let maximized: boolean;
|
||||
|
||||
const portfolio = getContext<PortfolioState>("portfolio");
|
||||
|
||||
$: docIndex = $portfolio.activeDocumentIndex;
|
||||
$: displayName = $portfolio.documents[docIndex]?.displayName || "";
|
||||
$: windowTitle = `${displayName}${displayName && " - "}Graphite`;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="title-bar">
|
||||
<LayoutRow class="header-part">
|
||||
{#if platform === "Mac"}
|
||||
<WindowButtonsMac {maximized} />
|
||||
{:else}
|
||||
<MenuBarInput />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
<LayoutRow class="header-part">
|
||||
<WindowTitle text={windowTitle} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="header-part">
|
||||
{#if platform === "Windows" || platform === "Linux"}
|
||||
<WindowButtonsWindows {maximized} />
|
||||
{:else if platform === "Web"}
|
||||
<WindowButtonsWeb />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.title-bar {
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.header-part {
|
||||
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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
|
||||
export let maximized = false;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-buttons-mac">
|
||||
<div class="close" title="Close" />
|
||||
<div class="minimize" title="Minimize" />
|
||||
<div class="zoom" title="Zoom" />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-buttons-mac {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
margin: 0 8px;
|
||||
|
||||
div {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
|
||||
& + div {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&.close {
|
||||
background: #ff5a52;
|
||||
}
|
||||
|
||||
&.minimize {
|
||||
background: #e6c029;
|
||||
}
|
||||
|
||||
&.zoom {
|
||||
background: #54c22b;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import { FullscreenState } from "@/state-providers/fullscreen";
|
||||
|
||||
const fullscreen = getContext<FullscreenState>("fullscreen");
|
||||
|
||||
$: windowFullscreen = $fullscreen.windowFullscreen;
|
||||
$: requestFullscreenHotkeys = fullscreen.keyboardLockApiSupported && !$fullscreen.keyboardLocked;
|
||||
|
||||
async function handleClick() {
|
||||
if (windowFullscreen) fullscreen.exitFullscreen();
|
||||
else fullscreen.enterFullscreen();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-buttons-web" on:click={() => handleClick()} tooltip={(windowFullscreen ? "Exit" : "Enter") + " Fullscreen (F11)"}>
|
||||
{#if requestFullscreenHotkeys}
|
||||
<TextLabel italic={true}>Go fullscreen to access all hotkeys</TextLabel>
|
||||
{/if}
|
||||
<IconLabel icon={windowFullscreen ? "FullscreenExit" : "FullscreenEnter"} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-buttons-web {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
|
||||
svg {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
.text-label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-6-lowergray);
|
||||
color: var(--color-f-white);
|
||||
|
||||
svg {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
|
||||
export let maximized = false;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-button windows minimize" tooltip="Minimize">
|
||||
<IconLabel icon={"WindowButtonWinMinimize"} />
|
||||
</LayoutRow>
|
||||
{#if !maximized}
|
||||
<LayoutRow class="window-button windows maximize" tooltip="Maximize">
|
||||
<IconLabel icon={"WindowButtonWinMaximize"} />
|
||||
</LayoutRow>
|
||||
{:else}
|
||||
<LayoutRow class="window-button windows restore-down" tooltip="Restore Down">
|
||||
<IconLabel icon={"WindowButtonWinRestoreDown"} />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
<LayoutRow class="window-button windows close" tooltip="Close">
|
||||
<IconLabel icon={"WindowButtonWinClose"} />
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-button.windows {
|
||||
flex: 0 0 auto;
|
||||
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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
export let text: string;
|
||||
</script>
|
||||
|
||||
<LayoutRow class="window-title">
|
||||
<TextLabel>{text}</TextLabel>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.window-title {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts" context="module">
|
||||
import Document from "@/components/panels/Document.svelte";
|
||||
import IconButton from "@/components/widgets/buttons/IconButton.svelte";
|
||||
import LayerTree from "@/components/panels/LayerTree.svelte";
|
||||
import NodeGraph from "@/components/panels/NodeGraph.svelte";
|
||||
import PopoverButton from "@/components/widgets/buttons/PopoverButton.svelte";
|
||||
import Properties from "@/components/panels/Properties.svelte";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.svelte";
|
||||
|
||||
const PANEL_COMPONENTS = {
|
||||
Document,
|
||||
LayerTree,
|
||||
NodeGraph,
|
||||
Properties,
|
||||
};
|
||||
type PanelTypes = keyof typeof PANEL_COMPONENTS;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext, tick } from "svelte";
|
||||
|
||||
import { platformIsMac } from "@/utility-functions/platform";
|
||||
|
||||
import { type LayoutKeysGroup, type Key } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
|
||||
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
|
||||
export let tabMinWidths = false;
|
||||
export let tabCloseButtons = false;
|
||||
export let tabLabels: { name: string; tooltip?: string }[];
|
||||
export let tabActiveIndex: number;
|
||||
export let panelType: PanelTypes | undefined = undefined;
|
||||
export let clickAction: ((index: number) => void) | undefined = undefined;
|
||||
export let closeAction: ((index: number) => void) | undefined = undefined;
|
||||
|
||||
let tabElements: LayoutRow[] = [];
|
||||
|
||||
function newDocument() {
|
||||
editor.instance.newDocumentDialog();
|
||||
}
|
||||
|
||||
function openDocument() {
|
||||
editor.instance.documentOpen();
|
||||
}
|
||||
|
||||
function platformModifiers(reservedKey: boolean): LayoutKeysGroup {
|
||||
// TODO: Remove this by properly feeding these keys from a layout provided by the backend
|
||||
|
||||
const ALT: Key = { key: "Alt", label: "Alt" };
|
||||
const COMMAND: Key = { key: "Command", label: "Command" };
|
||||
const CONTROL: Key = { key: "Control", label: "Ctrl" };
|
||||
|
||||
if (platformIsMac()) return reservedKey ? [ALT, COMMAND] : [COMMAND];
|
||||
return reservedKey ? [CONTROL, ALT] : [CONTROL];
|
||||
}
|
||||
|
||||
// TODO: Svelte: test this
|
||||
export async function scrollTabIntoView(newIndex: number) {
|
||||
await tick();
|
||||
tabElements[newIndex].div().scrollIntoView();
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="panel">
|
||||
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
||||
<LayoutRow class="tab-group" scrollableX={true}>
|
||||
{#each tabLabels as tabLabel, tabIndex (tabIndex)}
|
||||
<LayoutRow
|
||||
class="tab"
|
||||
classes={{ active: tabIndex === tabActiveIndex }}
|
||||
tooltip={tabLabel.tooltip || undefined}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.button === 0) clickAction?.(tabIndex);
|
||||
if (e.button === 1) closeAction?.(tabIndex);
|
||||
}}
|
||||
bind:this={tabElements[tabIndex]}
|
||||
>
|
||||
<TextLabel>{tabLabel.name}</TextLabel>
|
||||
{#if tabCloseButtons}
|
||||
<IconButton
|
||||
action={(e) => {
|
||||
e?.stopPropagation();
|
||||
closeAction?.(tabIndex);
|
||||
}}
|
||||
icon="CloseX"
|
||||
size={16}
|
||||
/>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
<PopoverButton icon="VerticalEllipsis">
|
||||
<TextLabel bold={true}>Panel Options</TextLabel>
|
||||
<TextLabel multiline={true}>Coming soon</TextLabel>
|
||||
</PopoverButton>
|
||||
</LayoutRow>
|
||||
<LayoutCol class="panel-body">
|
||||
{#if panelType}
|
||||
<svelte:component this={PANEL_COMPONENTS[panelType]} />
|
||||
{:else}
|
||||
<LayoutCol class="empty-panel">
|
||||
<LayoutCol class="content">
|
||||
<LayoutRow class="logotype">
|
||||
<IconLabel icon="GraphiteLogotypeSolid" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="actions">
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<TextButton label="New Document" icon="File" action={() => newDocument()} />
|
||||
</td>
|
||||
<td>
|
||||
<UserInputLabel keysWithLabelsGroups={[[...platformModifiers(true), { key: "KeyN", label: "N" }]]} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<TextButton label="Open Document" icon="Folder" action={() => openDocument()} />
|
||||
</td>
|
||||
<td>
|
||||
<UserInputLabel keysWithLabelsGroups={[[...platformModifiers(false), { key: "KeyO", label: "O" }]]} />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.panel {
|
||||
background: var(--color-1-nearblack);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-bar {
|
||||
height: 28px;
|
||||
min-height: auto;
|
||||
|
||||
&.min-widths .tab-group .tab {
|
||||
min-width: 120px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
flex: 1 1 100%;
|
||||
position: relative;
|
||||
|
||||
// This always hangs out at the end of the last tab, providing 16px (15px plus the 1px reserved for the separator line) to the right of the tabs.
|
||||
// When the last tab is selected, its bottom rounded fillet adds 16px to the width, which stretches the scrollbar width allocation in only that situation.
|
||||
// This pseudo-element ensures we always reserve that space to prevent the scrollbar from jumping when the last tab is selected.
|
||||
// There is unfortunately no apparent way to remove that 16px gap from the end of the scroll container, since negative margin does not reduce the scrollbar allocation.
|
||||
&::after {
|
||||
content: "";
|
||||
width: 15px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 0 1 auto;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
background: var(--color-3-darkgray);
|
||||
border-radius: 6px 6px 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%;
|
||||
flex-direction: column;
|
||||
|
||||
.empty-panel {
|
||||
background: var(--color-2-mildblack);
|
||||
margin: 4px;
|
||||
border-radius: 2px;
|
||||
justify-content: center;
|
||||
|
||||
.content {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
|
||||
.logotype {
|
||||
margin-bottom: 40px;
|
||||
|
||||
svg {
|
||||
width: auto;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
table {
|
||||
border-spacing: 8px;
|
||||
margin: -8px;
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.text-button:not(:hover) {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import DialogModal from "@/components/floating-menus/DialogModal.svelte";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.svelte";
|
||||
import Panel from "@/components/window/workspace/Panel.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { type Editor } from "@/wasm-communication/editor";
|
||||
import { type WorkspaceState } from "@/state-providers/workspace";
|
||||
import { type PortfolioState } from "@/state-providers/portfolio";
|
||||
import { type DialogState } from "@/state-providers/dialog";
|
||||
import { FrontendDocumentDetails } from "@/wasm-communication/messages";
|
||||
|
||||
const MIN_PANEL_SIZE = 100;
|
||||
const PANEL_SIZES = {
|
||||
/**/ root: 100,
|
||||
/* ├── */ content: 80,
|
||||
/* │ ├── */ document: 60,
|
||||
/* │ └── */ graph: 40,
|
||||
/* └── */ details: 20,
|
||||
/* ├── */ properties: 45,
|
||||
/* └── */ layers: 55,
|
||||
};
|
||||
|
||||
let panelSizes = PANEL_SIZES;
|
||||
let documentPanel: Panel;
|
||||
|
||||
$: activeDocumentIndex = $portfolio.activeDocumentIndex;
|
||||
$: nodeGraphVisible = $workspace.nodeGraphVisible;
|
||||
$: documentTabLabels = $portfolio.documents.map((doc: FrontendDocumentDetails) => {
|
||||
const name = doc.displayName;
|
||||
|
||||
if (!editor.instance.inDevelopmentMode()) return { name };
|
||||
|
||||
const tooltip = `Document ID ${doc.id}`;
|
||||
return { name, tooltip };
|
||||
});
|
||||
$: {
|
||||
scrollIntoView(activeDocumentIndex);
|
||||
}
|
||||
function scrollIntoView(activeDocumentIndex: number) {
|
||||
documentPanel?.scrollTabIntoView(activeDocumentIndex);
|
||||
}
|
||||
|
||||
const editor = getContext<Editor>("editor");
|
||||
const workspace = getContext<WorkspaceState>("workspace");
|
||||
const portfolio = getContext<PortfolioState>("portfolio");
|
||||
const dialog = getContext<DialogState>("dialog");
|
||||
|
||||
function resizePanel(e: PointerEvent) {
|
||||
const gutter = (e.target || undefined) as HTMLDivElement | undefined;
|
||||
const nextSibling = (gutter?.nextElementSibling || undefined) as HTMLDivElement | undefined;
|
||||
const prevSibling = (gutter?.previousElementSibling || undefined) as HTMLDivElement | undefined;
|
||||
const parentElement = (gutter?.parentElement || undefined) as HTMLDivElement | undefined;
|
||||
|
||||
const nextSiblingName = (nextSibling?.getAttribute("data-subdivision-name") || undefined) as keyof typeof PANEL_SIZES;
|
||||
const prevSiblingName = (prevSibling?.getAttribute("data-subdivision-name") || undefined) as keyof typeof PANEL_SIZES;
|
||||
|
||||
if (!gutter || !nextSibling || !prevSibling || !parentElement || !nextSiblingName || !prevSiblingName) return;
|
||||
|
||||
// Are we resizing horizontally?
|
||||
const isHorizontal = gutter.getAttribute("data-gutter-horizontal") !== null;
|
||||
|
||||
// Get the current size in px of the panels being resized and the gutter
|
||||
const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height;
|
||||
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
|
||||
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
|
||||
const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height;
|
||||
|
||||
// Measure the resizing panels as a percentage of all sibling panels
|
||||
const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize;
|
||||
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
|
||||
|
||||
// Prevent cursor flicker as mouse temporarily leaves the gutter
|
||||
gutter.setPointerCapture(e.pointerId);
|
||||
|
||||
const mouseStart = isHorizontal ? e.clientX : e.clientY;
|
||||
|
||||
const updatePosition = (e: PointerEvent): void => {
|
||||
const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
|
||||
let mouseDelta = mouseStart - mouseCurrent;
|
||||
|
||||
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
|
||||
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
|
||||
|
||||
panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
||||
panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
||||
|
||||
window.dispatchEvent(new CustomEvent("resize"));
|
||||
};
|
||||
|
||||
const cleanup = (e: PointerEvent): void => {
|
||||
gutter.releasePointerCapture(e.pointerId);
|
||||
|
||||
document.removeEventListener("pointermove", updatePosition);
|
||||
document.removeEventListener("pointerleave", cleanup);
|
||||
document.removeEventListener("pointerup", cleanup);
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", updatePosition);
|
||||
document.addEventListener("pointerleave", cleanup);
|
||||
document.addEventListener("pointerup", cleanup);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="workspace" data-workspace>
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["root"] }} data-subdivision-name="root">
|
||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["content"] }} data-subdivision-name="content">
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
|
||||
<Panel
|
||||
panelType={$portfolio.documents.length > 0 ? "Document" : undefined}
|
||||
tabCloseButtons={true}
|
||||
tabMinWidths={true}
|
||||
tabLabels={documentTabLabels}
|
||||
clickAction={(tabIndex) => editor.instance.selectDocument($portfolio.documents[tabIndex].id)}
|
||||
closeAction={(tabIndex) => editor.instance.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
||||
tabActiveIndex={$portfolio.activeDocumentIndex}
|
||||
bind:this={documentPanel}
|
||||
/>
|
||||
</LayoutRow>
|
||||
{#if nodeGraphVisible}
|
||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={resizePanel} />
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["graph"] }} data-subdivision-name="graph">
|
||||
<Panel panelType="NodeGraph" tabLabels={[{ name: "Node Graph" }]} tabActiveIndex={0} />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e)} />
|
||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
|
||||
<Panel panelType="Properties" tabLabels={[{ name: "Properties" }]} tabActiveIndex={0} />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} />
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
|
||||
<Panel panelType="LayerTree" tabLabels={[{ name: "Layer Tree" }]} tabActiveIndex={0} />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
{#if $dialog.visible}
|
||||
<DialogModal />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
.workspace {
|
||||
position: relative;
|
||||
flex: 1 1 100%;
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user