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:
Keavon Chambers
2023-01-13 01:05:20 -08:00
co-authored by Locria Cyber
parent 2f7c45771e
commit dcc3eadf44
239 changed files with 27757 additions and 13 deletions
@@ -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>
+58
View File
@@ -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 0255">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 0255`}
/>
{/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 &quot;color&quot; along the rainbow",
s: "Saturation component, the &quot;colorfulness&quot; 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>