Replace the Vue frontend with Svelte

This commit is contained in:
Keavon Chambers
2023-03-10 03:54:39 -08:00
parent e539e43483
commit 6e20ea538b
83 changed files with 10013 additions and 19687 deletions
@@ -1,127 +1,109 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import { type IconName } from "@/utility-functions/icons";
import { type IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
export default defineComponent({
emits: ["update:checked"],
props: {
checked: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {
id: `${Math.random()}`.substring(2),
};
},
computed: {
displayIcon(): IconName {
if (!this.checked && this.icon === "Checkmark") return "Empty12px";
// emits: ["update:checked"],
const dispatch = createEventDispatcher<{ checked: boolean }>();
return this.icon;
},
},
methods: {
isChecked() {
return this.checked;
},
toggleCheckboxFromLabel(e: KeyboardEvent) {
const target = (e.target || undefined) as HTMLLabelElement | undefined;
const previousSibling = (target?.previousSibling || undefined) as HTMLInputElement | undefined;
previousSibling?.click();
},
},
components: {
IconLabel,
LayoutRow,
},
});
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>
<template>
<LayoutRow class="checkbox-input">
<input
type="checkbox"
:id="`checkbox-input-${id}`"
:checked="checked"
@change="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)"
:disabled="disabled"
:tabindex="disabled ? -1 : 0"
/>
<label :class="{ disabled, checked }" :for="`checkbox-input-${id}`" @keydown.enter="(e) => toggleCheckboxFromLabel(e)" :title="tooltip">
<LayoutRow class="checkbox-box">
<IconLabel :icon="displayIcon" />
</LayoutRow>
</label>
</LayoutRow>
</template>
<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">
.checkbox-input {
flex: 0 0 auto;
align-items: center;
<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;
}
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;
// Unchecked
label {
display: flex;
height: 16px;
// Provides rounded corners for the :focus outline
border-radius: 2px;
.icon-label {
fill: var(--color-8-uppergray);
.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);
}
}
// Hovered
&:hover .checkbox-box {
background: var(--color-6-lowergray);
}
// Checked
input:checked + label {
.checkbox-box {
background: var(--color-e-nearwhite);
// Disabled
&.disabled .checkbox-box {
background: var(--color-4-dimgray);
}
}
.icon-label {
fill: var(--color-2-mildblack);
}
}
// Checked
input:checked + label {
.checkbox-box {
background: var(--color-e-nearwhite);
// Hovered
&:hover .checkbox-box {
background: var(--color-f-white);
}
.icon-label {
fill: var(--color-2-mildblack);
// Hovered
&.disabled .checkbox-box {
background: var(--color-8-uppergray);
}
}
// Hovered
&:hover .checkbox-box {
background: var(--color-f-white);
}
// Hovered
&.disabled .checkbox-box {
background: var(--color-8-uppergray);
}
}
}
</style>
@@ -1,133 +1,113 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import { Color } from "@/wasm-communication/messages";
import { Color } from "@/wasm-communication/messages";
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
import ColorPicker from "@/components/floating-menus/ColorPicker.svelte";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
export default defineComponent({
emits: ["update:value", "update:open"],
props: {
value: { type: Color as PropType<Color>, required: true },
noTransparency: { type: Boolean as PropType<boolean>, default: false }, // TODO: Rename to allowTransparency, also implement allowNone
disabled: { type: Boolean as PropType<boolean>, default: false }, // TODO: Design and implement
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
// emits: ["update:value"],
const dispatch = createEventDispatcher<{ value: Color }>();
// Bound through `v-model`
// TODO: See if this should be made to follow the pattern of DropdownInput.vue so this could be removed
open: { type: Boolean as PropType<boolean>, required: true },
},
data() {
return {
isOpen: false,
};
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newOpen: boolean) {
this.isOpen = newOpen;
},
isOpen(newIsOpen: boolean) {
this.$emit("update:open", newIsOpen);
},
},
methods: {
colorPickerUpdated(color: Color) {
this.$emit("update:value", color);
},
},
computed: {
chip() {
return undefined;
},
},
components: {
ColorPicker,
LayoutRow,
TextLabel,
},
});
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>
<template>
<LayoutRow class="color-input" :class="{ 'sharp-right-corners': sharpRightCorners }" :title="tooltip">
<button
:class="{ none: value.none, 'sharp-right-corners': sharpRightCorners }"
:style="{ '--chosen-color': value.toHexOptionalAlpha() }"
@click="() => $emit('update:open', true)"
tabindex="0"
data-floating-menu-spawner
>
<TextLabel :bold="true" class="chip" v-if="chip">{{ chip }}</TextLabel>
</button>
<ColorPicker v-model:open="isOpen" :color="value" @update:color="(color: Color) => colorPickerUpdated(color)" :allowNone="true" />
</LayoutRow>
</template>
<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 }) => {
value = detail;
dispatch("value", detail);
}}
allowNone={true}
/>
</LayoutRow>
<style lang="scss">
.color-input {
box-sizing: border-box;
position: relative;
border: 1px solid var(--color-5-dullgray);
border-radius: 2px;
padding: 1px;
> button {
<style lang="scss" global>
.color-input {
box-sizing: border-box;
position: relative;
overflow: hidden;
border: none;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
border-radius: 1px;
border: 1px solid var(--color-5-dullgray);
border-radius: 2px;
padding: 1px;
&::before {
content: "";
position: absolute;
> button {
position: relative;
overflow: hidden;
border: none;
margin: 0;
padding: 0;
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);
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);
}
}
&.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);
&.color-input.color-input > button {
outline-offset: 0;
}
.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);
> .floating-menu {
left: 50%;
bottom: 0;
}
}
&.color-input.color-input > button {
outline-offset: 0;
}
> .floating-menu {
left: 50%;
bottom: 0;
}
}
</style>
@@ -1,174 +1,163 @@
<script lang="ts">
import { defineComponent, type PropType, toRaw } from "vue";
import { createEventDispatcher } from "svelte";
import { type MenuListEntry } from "@/wasm-communication/messages";
import { type MenuListEntry } from "@/wasm-communication/messages";
import MenuList from "@/components/floating-menus/MenuList.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
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: "-" };
const DASH_ENTRY = { label: "-" };
export default defineComponent({
emits: ["update:selectedIndex"],
props: {
entries: { type: Array as PropType<MenuListEntry[][]>, required: true },
selectedIndex: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
drawIcon: { type: Boolean as PropType<boolean>, default: false },
interactive: { type: Boolean as PropType<boolean>, default: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
activeEntry: this.makeActiveEntry(this.selectedIndex),
activeEntrySkipWatcher: false,
open: false,
minWidth: 0,
};
},
watch: {
// Called only when `selectedIndex` is changed from outside this component (with v-model)
selectedIndex() {
this.activeEntrySkipWatcher = true;
this.activeEntry = this.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)
activeEntry(newActiveEntry: MenuListEntry) {
if (this.activeEntrySkipWatcher) {
this.activeEntrySkipWatcher = false;
return;
}
// emits: ["update:selectedIndex"],
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
// `toRaw()` pulls it out of the Vue proxy
if (toRaw(newActiveEntry) === DASH_ENTRY) return;
let menuList: MenuList;
let self: LayoutRow;
this.$emit("update:selectedIndex", this.entries.flat().indexOf(newActiveEntry));
},
},
methods: {
makeActiveEntry(): MenuListEntry {
const entries = this.entries.flat();
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;
if (this.selectedIndex !== undefined && this.selectedIndex >= 0 && this.selectedIndex < entries.length) {
return entries[this.selectedIndex];
}
return DASH_ENTRY;
},
keydown(e: KeyboardEvent) {
(this.$refs.menuList as typeof MenuList | undefined)?.keydown(e, false);
},
unFocusDropdownBox(e: FocusEvent) {
const blurTarget = (e.target as HTMLDivElement | undefined)?.closest("[data-dropdown-input]");
const self: HTMLDivElement | undefined = this.$el;
if (blurTarget !== self) this.open = false;
},
},
components: {
IconLabel,
LayoutRow,
MenuList,
TextLabel,
},
});
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
function watchSelectedIndex() {
activeEntrySkipWatcher = true;
activeEntry = makeActiveEntry();
}
// Called when the `activeEntry` two-way binding on this component's MenuList component is changed, 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>
<template>
<LayoutRow class="dropdown-input" data-dropdown-input>
<LayoutRow
class="dropdown-box"
:class="{ disabled, open, 'sharp-right-corners': sharpRightCorners }"
:style="{ minWidth: `${minWidth}px` }"
:title="tooltip"
@click="() => !disabled && (open = true)"
@blur="(e: FocusEvent) => unFocusDropdownBox(e)"
@keydown="(e: KeyboardEvent) => keydown(e)"
:tabindex="disabled ? -1 : 0"
data-floating-menu-spawner
>
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<TextLabel class="dropdown-label">{{ activeEntry.label }}</TextLabel>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow>
<MenuList
v-model:activeEntry="activeEntry"
v-model:open="open"
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
:entries="entries"
:drawIcon="drawIcon"
:interactive="interactive"
:direction="'Bottom'"
:scrollableY="true"
ref="menuList"
/>
<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>
</template>
<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">
.dropdown-input {
position: relative;
<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-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-label {
margin: 0;
margin-left: 8px;
flex: 1 1 100%;
}
.dropdown-icon {
margin: 4px;
flex: 0 0 auto;
.dropdown-icon {
margin: 4px;
flex: 0 0 auto;
& + .dropdown-label {
margin-left: 0;
& + .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);
}
}
}
.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;
}
}
.menu-list .floating-menu-container .floating-menu-content {
max-height: 400px;
}
}
</style>
@@ -1,194 +1,198 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import { platformIsMac } from "@/utility-functions/platform";
import { platformIsMac } from "@/utility-functions/platform";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
export default defineComponent({
emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
props: {
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
spellcheck: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
textarea: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
placeholder: { type: String as PropType<string>, required: false },
},
data() {
return {
id: `${Math.random()}`.substring(2),
macKeyboardLayout: platformIsMac(),
};
},
methods: {
// Select (highlight) all the text. For technical reasons, it is necessary to pass the current text.
selectAllText(currentText: string) {
const inputElement = this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined;
if (!inputElement) return;
// emits: ["update:value", "textFocused", "textChanged", "cancelTextChange"],
const dispatch = createEventDispatcher<{
value: string;
textFocused: undefined;
textChanged: undefined;
cancelTextChange: undefined;
}>();
// Setting the value directly is required to make `inputElement.select()` work
inputElement.value = currentText;
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;
inputElement.select();
},
unFocus() {
(this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined)?.blur();
},
getInputElementValue(): string | undefined {
return (this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined)?.value;
},
setInputElementValue(value: string) {
const inputElement = this.$refs.input as HTMLInputElement | HTMLTextAreaElement | undefined;
if (inputElement) inputElement.value = value;
},
},
computed: {
inputValue: {
get() {
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
components: { LayoutRow },
});
let inputOrTextarea: HTMLInputElement | HTMLTextAreaElement;
let id = `${Math.random()}`.substring(2);
let macKeyboardLayout = platformIsMac();
$: inputValue = value;
$: dispatch("value", inputValue);
// Select (highlight) all the text. For technical reasons, it is necessary to pass the current text.
export function selectAllText(currentText: string) {
// Setting the value directly is required to make the following `select()` call work
inputOrTextarea.value = currentText;
inputOrTextarea.select();
}
export function focus() {
inputOrTextarea.focus();
}
export function unFocus() {
inputOrTextarea.blur();
}
export function getValue(): string {
return inputOrTextarea.value;
}
export function setInputElementValue(value: string) {
inputOrTextarea.value = value;
}
export function element(): HTMLInputElement | HTMLTextAreaElement {
return inputOrTextarea;
}
</script>
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
<template>
<LayoutRow class="field-input" :class="{ disabled, 'sharp-right-corners': sharpRightCorners }" :title="tooltip">
<LayoutRow class={`field-input ${className}`} classes={{ disabled, "sharp-right-corners": sharpRightCorners, ...classes }} style={styleName} {styles} {tooltip}>
{#if !textarea}
<input
type="text"
v-if="!textarea"
:class="{ 'has-label': label }"
:id="`field-input-${id}`"
ref="input"
v-model="inputValue"
:spellcheck="spellcheck"
:disabled="disabled"
:placeholder="placeholder"
@focus="() => $emit('textFocused')"
@blur="() => $emit('textChanged')"
@change="() => $emit('textChanged')"
@keydown.enter="() => $emit('textChanged')"
@keydown.esc="() => $emit('cancelTextChange')"
class:has-label={label}
id={`field-input-${id}`}
{spellcheck}
{disabled}
{placeholder}
bind:value={inputValue}
bind:this={inputOrTextarea}
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
v-else
:class="{ 'has-label': label }"
:id="`field-input-${id}`"
class:has-label={label}
id={`field-input-${id}`}
class="scrollable-y"
data-scrollable-y
ref="input"
v-model="inputValue"
:spellcheck="spellcheck"
:disabled="disabled"
@focus="() => $emit('textFocused')"
@blur="() => $emit('textChanged')"
@change="() => $emit('textChanged')"
@keydown.ctrl.enter="() => !macKeyboardLayout && $emit('textChanged')"
@keydown.meta.enter="() => macKeyboardLayout && $emit('textChanged')"
@keydown.esc="() => $emit('cancelTextChange')"
></textarea>
<label v-if="label" :for="`field-input-${id}`">{{ label }}</label>
<slot></slot>
</LayoutRow>
</template>
{spellcheck}
{disabled}
bind:value={inputValue}
bind:this={inputOrTextarea}
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">
.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;
<style lang="scss" global>
.field-input {
min-width: 80px;
height: auto;
position: relative;
border-radius: 2px;
background: var(--color-1-nearblack);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
flex-direction: row-reverse;
&:not(.disabled) label {
cursor: text;
}
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;
}
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);
&:not(.disabled) label {
cursor: text;
}
&::selection {
background-color: var(--color-5-dullgray);
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);
// 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));
&::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;
input {
// text-align: center;
&:not(:focus).has-label {
text-align: right;
margin-left: 0;
margin-right: 8px;
&:not(:focus).has-label {
text-align: right;
margin-left: 0;
margin-right: 8px;
}
&:focus {
text-align: left;
& + label {
display: none;
}
}
}
&:focus {
text-align: left;
textarea {
min-height: calc(18px * 3);
margin: 3px;
padding: 0 5px;
box-sizing: border-box;
resize: vertical;
}
& + label {
display: none;
&.disabled {
background: var(--color-2-mildblack);
label,
input,
textarea {
color: var(--color-8-uppergray);
}
}
}
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>
@@ -1,189 +1,185 @@
<script lang="ts">
import { defineComponent, nextTick, type PropType } from "vue";
import { createEventDispatcher, getContext, onMount, tick } from "svelte";
import { type MenuListEntry } from "@/wasm-communication/messages";
import { type MenuListEntry } from "@/wasm-communication/messages";
import MenuList from "@/components/floating-menus/MenuList.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
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";
export default defineComponent({
inject: ["fonts"],
emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
props: {
fontFamily: { type: String as PropType<string>, required: true },
fontStyle: { type: String as PropType<string>, required: true },
isStyle: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
open: false,
entries: [] as MenuListEntry[],
activeEntry: undefined as MenuListEntry | undefined,
entriesStart: 0,
minWidth: this.isStyle ? 0 : 300,
};
},
async mounted() {
this.entries = await this.getEntries();
this.activeEntry = this.getActiveEntry(this.entries);
},
methods: {
async setOpen(): Promise<void> {
this.open = true;
const fonts = getContext<FontsState>("fonts");
// Scroll to the active entry (the scroller div does not yet exist so we must wait for Vue to render)
await nextTick();
// emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
const dispatch = createEventDispatcher<{
fontFamily: string;
fontStyle: string;
changeFont: { fontFamily: string; fontStyle: string; fontFileUrl: string | undefined };
}>();
if (this.activeEntry) {
const index = this.entries.indexOf(this.activeEntry);
(this.$refs.menuList as typeof MenuList | undefined)?.scrollViewTo(0, Math.max(0, index * 20 - 190));
}
},
toggleOpen(): void {
if (!this.disabled) {
this.open = !this.open;
let menuList: MenuList;
if (this.open) this.setOpen();
}
},
keydown(e: KeyboardEvent): void {
(this.$refs.menuList as typeof MenuList | undefined)?.keydown(e, false);
},
async selectFont(newName: string): Promise<void> {
let fontFamily;
let fontStyle;
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;
if (this.isStyle) {
this.$emit("update:fontStyle", newName);
let open = false;
let entries: MenuListEntry[] = [];
let activeEntry: MenuListEntry | undefined = undefined;
let minWidth = isStyle ? 0 : 300;
fontFamily = this.fontFamily;
fontStyle = newName;
} else {
this.$emit("update:fontFamily", newName);
$: fontFamily, fontStyle, watchFont();
fontFamily = newName;
fontStyle = "Normal (400)";
}
async function watchFont(): Promise<void> {
// We set this function's result to a local variable to avoid reading from `entries` which causes Svelte to trigger an update that results in an infinite loop
const newEntries = await getEntries();
entries = newEntries;
activeEntry = getActiveEntry(newEntries);
}
const fontFileUrl = await this.fonts.getFontFileUrl(fontFamily, fontStyle);
this.$emit("changeFont", { fontFamily, fontStyle, fontFileUrl });
},
async getEntries(): Promise<MenuListEntry[]> {
const x = this.isStyle ? this.fonts.getFontStyles(this.fontFamily) : this.fonts.fontNames();
return (await x).map((entry: { name: string; url: URL | undefined }) => ({
label: entry.name,
value: entry.name,
font: entry.url,
action: () => this.selectFont(entry.name),
}));
},
getActiveEntry(entries: MenuListEntry[]): MenuListEntry {
const selectedChoice = this.isStyle ? this.fontStyle : this.fontFamily;
async function setOpen(): Promise<void> {
open = true;
return entries.find((entry) => entry.value === selectedChoice) as MenuListEntry;
},
},
watch: {
async fontFamily() {
this.entries = await this.getEntries();
this.activeEntry = this.getActiveEntry(this.entries);
},
async fontStyle() {
this.entries = await this.getEntries();
this.activeEntry = this.getActiveEntry(this.entries);
},
},
components: {
IconLabel,
LayoutRow,
MenuList,
TextLabel,
},
});
// 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);
});
</script>
<!-- TODO: Combine this widget into the DropdownInput widget -->
<template>
<LayoutRow class="font-input">
<LayoutRow
class="dropdown-box"
:class="{ disabled, 'sharp-right-corners': sharpRightCorners }"
:style="{ minWidth: `${minWidth}px` }"
:title="tooltip"
:tabindex="disabled ? -1 : 0"
@click="toggleOpen"
@keydown="keydown"
data-floating-menu-spawner
>
<TextLabel class="dropdown-label">{{ activeEntry?.value || "" }}</TextLabel>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow>
<MenuList
v-model:activeEntry="activeEntry"
v-model:open="open"
:entries="[entries]"
:minWidth="isStyle ? 0 : minWidth"
:virtualScrollingEntryHeight="isStyle ? 0 : 20"
:scrollableY="true"
@naturalWidth="(newNaturalWidth: number) => (isStyle && (minWidth = newNaturalWidth))"
ref="menuList"
></MenuList>
<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>
</template>
<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">
.font-input {
position: relative;
<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-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-label {
margin: 0;
margin-left: 8px;
flex: 1 1 100%;
}
.dropdown-arrow {
margin: 6px 2px;
flex: 0 0 auto;
}
.dropdown-arrow {
margin: 6px 2px;
flex: 0 0 auto;
}
&:hover,
&.open {
background: var(--color-6-lowergray);
&:hover,
&.open {
background: var(--color-6-lowergray);
span {
color: var(--color-f-white);
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);
}
}
}
&.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;
}
}
.menu-list .floating-menu-container .floating-menu-content {
max-height: 400px;
padding: 4px 0;
}
}
</style>
@@ -1,161 +1,142 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import { currentDraggingElement } from "@/io-managers/drag";
import { currentDraggingElement } from "@/io-managers/drag";
import type { LayerType, LayerTypeData } from "@/wasm-communication/messages";
import { layerTypeData } from "@/wasm-communication/messages";
import type { LayerType, LayerTypeData } from "@/wasm-communication/messages";
import { layerTypeData } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
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";
export default defineComponent({
emits: ["update:value"],
props: {
value: { type: String as PropType<string | undefined>, required: false },
layerName: { type: String as PropType<string | undefined>, required: false },
layerType: { type: String as PropType<LayerType | undefined>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
hoveringDrop: false,
};
},
computed: {
droppable() {
return this.hoveringDrop && currentDraggingElement();
},
},
methods: {
dragOver(e: DragEvent): void {
this.hoveringDrop = true;
// 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();
},
dragLeave(): void {
this.hoveringDrop = false;
},
drop(e: DragEvent): void {
this.hoveringDrop = false;
const element = currentDraggingElement();
const layerPath = element?.getAttribute("data-layer") || undefined;
dispatch("value", layerPath);
}
}
if (layerPath) {
e.preventDefault();
this.$emit("update:value", layerPath);
}
},
clearLayer(): void {
this.$emit("update:value", undefined);
},
layerTypeData(layerType: LayerType): LayerTypeData {
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
},
},
components: {
IconButton,
IconLabel,
LayoutRow,
TextLabel,
},
});
function getLayerTypeData(layerType: LayerType): LayerTypeData {
return layerTypeData(layerType) || { name: "Error", icon: "Info" };
}
</script>
<template>
<LayoutRow
class="layer-reference-input"
:class="{ disabled, droppable, 'sharp-right-corners': sharpRightCorners }"
:title="tooltip"
@dragover="(e: DragEvent) => !disabled && dragOver(e)"
@dragleave="() => !disabled && dragLeave()"
@drop="(e: DragEvent) => !disabled && drop(e)"
>
<template v-if="value === undefined || droppable">
<LayoutRow class="drop-zone"></LayoutRow>
<TextLabel :italic="true">{{ droppable ? "Drop" : "Drag" }} Layer Here</TextLabel>
</template>
<template v-if="value !== undefined && !droppable">
<IconLabel v-if="layerName !== undefined && layerType" :icon="layerTypeData(layerType).icon" class="layer-icon" />
<TextLabel v-if="layerName !== undefined && layerType" :italic="layerName === ''" class="layer-name">{{ layerName || layerTypeData(layerType).name }}</TextLabel>
<TextLabel :bold="true" :italic="true" v-else class="missing">Layer Missing</TextLabel>
</template>
<IconButton v-if="value !== undefined && !droppable" :icon="'CloseX'" :size="16" :disabled="disabled" :action="() => clearLayer()" />
</LayoutRow>
</template>
<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">
.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
color: #d6536e;
}
&.layer-name {
text-align: left;
}
}
.icon-button {
margin: 4px;
margin-left: 0;
}
&.disabled {
background: var(--color-2-mildblack);
<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 {
border: 1px dashed var(--color-4-dimgray);
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 {
color: var(--color-8-uppergray);
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-label svg {
fill: var(--color-8-uppergray);
.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>
@@ -1,30 +1,49 @@
<script lang="ts">
import { defineComponent } from "vue";
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 { 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.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
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";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type MenuListInstance = InstanceType<typeof MenuList>;
// 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"],
];
// 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");
export default defineComponent({
inject: ["editor"],
mounted() {
this.editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => {
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.open = true;
entries = entries;
} else {
throw new Error("The menu bar floating menu has no associated ref");
}
}
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);
@@ -38,7 +57,7 @@ export default defineComponent({
...entry,
// Shared names with fields that need to be converted from the type used in `MenuBarEntry` to that of `MenuListEntry`
action: (): void => this.editor.instance.updateLayout(updateMenuBarLayout.layoutTarget, entry.action.widgetId, undefined),
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`
@@ -49,106 +68,81 @@ export default defineComponent({
ref: undefined,
});
this.entries = updateMenuBarLayout.layout.map(menuBarEntryToMenuListEntry);
entries = updateMenuBarLayout.layout.map(menuBarEntryToMenuListEntry);
});
},
methods: {
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");
},
unFocusEntry(menuListEntry: MenuListEntry, e: FocusEvent) {
const blurTarget = (e.target as HTMLElement | undefined)?.closest("[data-menu-bar-input]");
const self: HTMLDivElement | undefined = this.$el;
if (blurTarget !== self && menuListEntry.ref) menuListEntry.ref.isOpen = false;
},
},
data() {
return {
entries: [] as MenuListEntry[],
open: false,
};
},
components: {
IconLabel,
MenuList,
TextLabel,
},
});
});
</script>
<template>
<div class="menu-bar-input" data-menu-bar-input>
<div class="entry-container" v-for="(entry, index) in entries" :key="index">
<div class="menu-bar-input" data-menu-bar-input>
{#each entries as entry, index (index)}
<div class="entry-container">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div
@click="(e: MouseEvent) => clickEntry(entry, e)"
@blur="(e: FocusEvent) => unFocusEntry(entry, e)"
@keydown="(e: KeyboardEvent) => entry.ref?.keydown(e, false)"
on:click={(e) => clickEntry(entry, e)}
on:keydown={(e) => entry.ref?.keydown(e, false)}
class="entry"
:class="{ open: entry.ref?.isOpen }"
class:open={entry.ref?.open}
tabindex="0"
:data-floating-menu-spawner="entry.children && entry.children.length > 0 ? '' : 'no-hover-transfer'"
data-floating-menu-spawner={entry.children && entry.children.length > 0 ? "" : "no-hover-transfer"}
>
<IconLabel v-if="entry.icon" :icon="entry.icon" />
<TextLabel v-if="entry.label">{{ entry.label }}</TextLabel>
{#if entry.icon}
<IconLabel icon={entry.icon} />
{/if}
{#if entry.label}
<TextLabel>{entry.label}</TextLabel>
{/if}
</div>
<MenuList
v-if="entry.children && entry.children.length > 0"
:open="entry.ref?.open || false"
:entries="entry.children || []"
:direction="'Bottom'"
:minWidth="240"
:drawIcon="true"
:ref="(ref: MenuListInstance): void => (ref && (entry.ref = ref), undefined)"
/>
{#if entry.children && entry.children.length > 0}
<MenuList
on:open={({ detail }) => {
if (entry.ref) entry.ref.open = detail;
}}
open={entry.ref?.open || false}
entries={entry.children || []}
direction="Bottom"
minWidth={240}
drawIcon={true}
bind:this={entry.ref}
/>
{/if}
</div>
</div>
</template>
{/each}
</div>
<style lang="scss">
.menu-bar-input {
display: flex;
.entry-container {
<style lang="scss" global>
.menu-bar-input {
display: flex;
position: relative;
.entry {
.entry-container {
display: flex;
align-items: center;
white-space: nowrap;
padding: 0 8px;
background: none;
border: 0;
margin: 0;
position: relative;
svg {
fill: var(--color-e-nearwhite);
}
&:hover,
&.open {
background: var(--color-6-lowergray);
.entry {
display: flex;
align-items: center;
white-space: nowrap;
padding: 0 8px;
background: none;
border: 0;
margin: 0;
svg {
fill: var(--color-f-white);
fill: var(--color-e-nearwhite);
}
span {
color: var(--color-f-white);
&:hover,
&.open {
background: var(--color-6-lowergray);
svg {
fill: var(--color-f-white);
}
span {
color: var(--color-f-white);
}
}
}
}
}
}
</style>
@@ -1,479 +1,488 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
export default defineComponent({
emits: ["update:value"],
props: {
// Label
label: { type: String as PropType<string>, required: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
// emits: ["update:value"],
const dispatch = createEventDispatcher<{ value: number | undefined }>();
// Disabled
disabled: { type: Boolean as PropType<boolean>, default: false },
// Label
export let label: string | undefined = undefined;
export let tooltip: string | undefined = undefined;
// Value
value: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
min: { type: Number as PropType<number>, required: false },
max: { type: Number as PropType<number>, required: false },
isInteger: { type: Boolean as PropType<boolean>, default: false },
// Disabled
export let disabled = false;
// Number presentation
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 },
unit: { type: String as PropType<string>, default: "" },
unitIsHiddenWhenEditing: { type: Boolean as PropType<boolean>, default: true },
// 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;
// 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.
mode: { type: String as PropType<NumberInputMode>, default: "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`.
step: { type: Number as PropType<number>, default: 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.
incrementBehavior: { type: String as PropType<NumberInputIncrementBehavior>, default: "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.
rangeMin: { type: Number as PropType<number>, default: 0 },
rangeMax: { type: Number as PropType<number>, default: 1 },
// Number presentation
export let displayDecimalPlaces = 3;
export let unit = "";
export let unitIsHiddenWhenEditing = true;
// Styling
minWidth: { type: Number as PropType<number>, default: 0 },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
// 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;
// Callbacks
incrementCallbackIncrease: { type: Function as PropType<() => void>, required: false },
incrementCallbackDecrease: { type: Function as PropType<() => void>, required: false },
},
data() {
return {
text: this.displayText(this.value),
editing: false,
// Stays in sync with a binding to the actual input range slider element.
rangeSliderValue: this.value !== undefined ? this.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.
rangeSliderValueAsRendered: this.value !== undefined ? this.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.
rangeSliderClickDragState: "default" as "default" | "mousedown" | "dragging",
// Styling
export let minWidth = 0;
export let sharpRightCorners = false;
// Callbacks
export let incrementCallbackIncrease: (() => void) | undefined = undefined;
export let incrementCallbackDecrease: (() => void) | undefined = undefined;
let self: FieldInput;
let text = displayText(value, displayDecimalPlaces, unit);
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
function watchValue(value: number | undefined) {
// Don't update if the slider is currently being dragged (we don't want the backend fighting with the user's drag)
if (rangeSliderClickDragState === "dragging") return;
// 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, displayDecimalPlaces, unit);
}
function onSliderInput() {
// 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, min, max, displayDecimalPlaces, unit);
}
function onSliderPointerDown() {
// 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 onSliderPointerUp() {
// User clicked but didn't drag, so we focus the text input element
if (rangeSliderClickDragState === "mousedown") {
const inputElement = self.element().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;
self.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, min, max, displayDecimalPlaces, unit);
editing = false;
self.unFocus();
}
function onCancelTextChange() {
updateValue(undefined, min, max, displayDecimalPlaces, unit);
editing = false;
self.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, min, max, displayDecimalPlaces, unit);
},
Multiply: () => {
const directionMultiplier = direction === "Increase" ? step : 1 / step;
updateValue(value !== undefined ? value * directionMultiplier : undefined, min, max, displayDecimalPlaces, unit);
},
Callback: () => {
if (direction === "Increase") incrementCallbackIncrease?.();
if (direction === "Decrease") incrementCallbackDecrease?.();
},
None: () => {},
};
},
computed: {
sliderStepValue() {
const step = this.step === undefined ? 1 : this.step;
return this.isInteger ? step : "any";
},
},
methods: {
sliderInput() {
// Keep only 4 digits after the decimal point
const ROUNDING_EXPONENT = 4;
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
const roundedValue = Math.round(this.rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE;
const action = actions[incrementBehavior];
action();
}
// Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox
if (this.value !== undefined && Math.abs(this.value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
return;
}
function updateValue(newValue: number | undefined, min: number | undefined, max: number | undefined, displayDecimalPlaces: number, unit: string) {
// Check if the new value is valid, otherwise we use the old value (rounded if it's an integer)
const nowValid = value !== undefined && isInteger ? Math.round(value) : value;
let cleaned = newValue !== undefined ? newValue : nowValid;
// The first event upon mousedown means we transition to a "mousedown" state
if (this.rangeSliderClickDragState === "default") {
this.rangeSliderClickDragState = "mousedown";
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);
// Exit early because we don't want to use the value set by where on the track the user pressed
return;
}
text = displayText(cleaned, displayDecimalPlaces, unit);
// The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider
if (this.rangeSliderClickDragState === "mousedown") {
this.rangeSliderClickDragState = "dragging";
}
if (newValue !== undefined) dispatch("value", cleaned);
}
// If we're in a dragging state, we want to use the new slider value
this.rangeSliderValueAsRendered = roundedValue;
this.updateValue(roundedValue);
},
sliderPointerDown() {
// We want to render the fake slider thumb at the old position, which is still the number held by `value`
this.rangeSliderValueAsRendered = this.value || 0;
function displayText(value: number | undefined, displayDecimalPlaces: number, unit: string): string {
if (value === undefined) return "-";
// 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.
},
sliderPointerUp() {
// User clicked but didn't drag, so we focus the text input element
if (this.rangeSliderClickDragState === "mousedown") {
const fieldInput = this.$refs.fieldInput as typeof FieldInput | undefined;
const inputElement = fieldInput?.$el.querySelector("[data-input-element]") as HTMLInputElement | undefined;
if (!inputElement) 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);
// Set the slider position back to the original position to undo the user moving it
this.rangeSliderValue = this.rangeSliderValueAsRendered;
const displayValue = Math.round(value * roundingPower) / roundingPower;
// Begin editing the number text field
inputElement.focus();
}
return `${displayValue}${unPluralize(unit, value)}`;
}
// Releasing the mouse means we can reset the state machine
this.rangeSliderClickDragState = "default";
},
onTextFocused() {
if (this.value === undefined) this.text = "";
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
else this.text = `${this.value}${unPluralize(this.unit, this.value)}`;
this.editing = true;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.selectAllText(this.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)
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 (!this.editing) return;
const parsed = parseFloat(this.text);
const newValue = Number.isNaN(parsed) ? undefined : parsed;
this.updateValue(newValue);
this.editing = false;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
},
onCancelTextChange() {
this.updateValue(undefined);
this.editing = false;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
},
onIncrement(direction: "Decrease" | "Increase") {
if (this.value === undefined) return;
const actions = {
Add: (): void => {
const directionAddend = direction === "Increase" ? this.step : -this.step;
this.updateValue(this.value !== undefined ? this.value + directionAddend : undefined);
},
Multiply: (): void => {
const directionMultiplier = direction === "Increase" ? this.step : 1 / this.step;
this.updateValue(this.value !== undefined ? this.value * directionMultiplier : undefined);
},
Callback: (): void => {
if (direction === "Increase") this.incrementCallbackIncrease?.();
if (direction === "Decrease") this.incrementCallbackDecrease?.();
},
None: (): void => undefined,
};
const action = actions[this.incrementBehavior];
action();
},
updateValue(newValue: number | undefined) {
const nowValid = this.value !== undefined && this.isInteger ? Math.round(this.value) : this.value;
let cleaned = newValue !== undefined ? newValue : nowValid;
if (typeof this.min === "number" && !Number.isNaN(this.min) && cleaned !== undefined) cleaned = Math.max(cleaned, this.min);
if (typeof this.max === "number" && !Number.isNaN(this.max) && cleaned !== undefined) cleaned = Math.min(cleaned, this.max);
// Required as the call to update:value can, not change the value
this.text = this.displayText(this.value);
if (newValue !== undefined) this.$emit("update:value", cleaned);
},
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(this.displayDecimalPlaces - leftSideDigits, 0);
const displayValue = Math.round(value * roundingPower) / roundingPower;
return `${displayValue}${unPluralize(this.unit, value)}`;
},
},
watch: {
// Called only when `value` is changed from outside this component (with v-model)
value(newValue: number | undefined) {
// Draw a dash if the value is undefined
if (newValue === undefined) {
this.text = "-";
return;
}
// Update the range slider with the new value
this.rangeSliderValue = newValue;
this.rangeSliderValueAsRendered = newValue;
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
let sanitized = newValue;
if (typeof this.min === "number") sanitized = Math.max(sanitized, this.min);
if (typeof this.max === "number") sanitized = Math.min(sanitized, this.max);
this.text = this.displayText(sanitized);
},
},
components: { FieldInput },
});
function unPluralize(unit: string, value: number): string {
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
return unit;
}
function unPluralize(unit: string, value: number): string {
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
return unit;
}
</script>
<template>
<FieldInput
class="number-input"
:class="mode.toLocaleLowerCase()"
v-model:value="text"
:label="label"
:spellcheck="false"
:disabled="disabled"
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined, '--progress-factor': (rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin) }"
:tooltip="tooltip"
:sharpRightCorners="sharpRightCorners"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
ref="fieldInput"
>
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow left" @click="() => onIncrement('Decrease')" tabindex="-1"></button>
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow right" @click="() => onIncrement('Increase')" tabindex="-1"></button>
<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={self}
>
{#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' }"
v-if="mode === 'Range' && value !== undefined"
v-model="rangeSliderValue"
:min="rangeMin"
:max="rangeMax"
:step="sliderStepValue"
:disabled="disabled"
@input="() => sliderInput()"
@pointerdown="() => sliderPointerDown()"
@pointerup="() => sliderPointerUp()"
class:hidden={rangeSliderClickDragState === "mousedown"}
bind:value={rangeSliderValue}
min={rangeMin}
max={rangeMax}
step={sliderStepValue}
{disabled}
on:input={onSliderInput}
on:pointerdown={onSliderPointerDown}
on:pointerup={onSliderPointerUp}
tabindex="-1"
/>
<div v-if="value !== undefined && rangeSliderClickDragState === 'mousedown'" class="fake-slider-thumb"></div>
<div v-if="value !== undefined" class="slider-progress"></div>
</FieldInput>
</template>
{/if}
{#if value !== undefined}
{#if value !== undefined && rangeSliderClickDragState === "mousedown"}
<div class="fake-slider-thumb" />
{/if}
<div class="slider-progress" />
{/if}
</FieldInput>
<style lang="scss">
.number-input {
&.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;
<style lang="scss" global>
.number-input {
input {
text-align: center;
}
input[type="text"]:not(:focus).has-label {
margin-right: 16px;
}
&.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;
}
// Hide the increment arrows when entering text, disabled, or not hovered
input[type="text"]:focus ~ .arrow,
&.disabled .arrow,
&:not(:hover) .arrow {
display: none;
}
input[type="text"]:not(:focus).has-label {
margin-right: 16px;
}
// 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);
// Hide the increment arrows when entering text, disabled, or not hovered
input[type="text"]:focus ~ .arrow,
&.disabled .arrow,
&:not(:hover) .arrow {
display: none;
}
&:hover {
background: var(--color-6-lowergray);
// 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);
&.right::before {
border-color: transparent transparent transparent var(--color-f-white);
&: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;
}
}
&.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;
}
}
&.right {
right: 0;
padding-left: 7px;
padding-right: 6px;
// 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: "";
display: block;
width: 0;
height: 0;
border-style: solid;
border-width: 3px 0 3px 3px;
border-color: transparent transparent transparent var(--color-e-nearwhite);
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
}
}
&.left {
left: 0;
padding-left: 6px;
padding-right: 7px;
.slider-progress {
position: absolute;
top: 2px;
bottom: 2px;
left: 2px;
right: 2px;
pointer-events: none;
&::after {
&::before {
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;
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;
}
}
}
}
&.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>
@@ -1,49 +1,36 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { type IconName } from "@/utility-functions/icons";
import { type IconName } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.svelte";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
export default defineComponent({
emits: ["update:checked"],
props: {
checked: { type: Boolean as PropType<boolean>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
components: {
CheckboxInput,
LayoutRow,
},
});
export let checked: boolean;
export let disabled = false;
export let icon: IconName = "Checkmark";
export let tooltip: string | undefined = undefined;
</script>
<template>
<LayoutRow class="optional-input" :class="disabled">
<CheckboxInput :checked="checked" :disabled="disabled" @input="(e: Event) => $emit('update:checked', (e.target as HTMLInputElement).checked)" :icon="icon" :tooltip="tooltip" />
</LayoutRow>
</template>
<LayoutRow class="optional-input" classes={{ disabled }}>
<CheckboxInput {checked} on:checked {disabled} {icon} {tooltip} />
</LayoutRow>
<style lang="scss">
.optional-input {
flex-grow: 0;
<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;
.checkbox-input 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 .checkbox-input label {
border: 1px solid var(--color-4-dimgray);
}
}
&.disabled label {
border: 1px solid var(--color-4-dimgray);
}
}
</style>
@@ -1,123 +1,119 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import { type RadioEntries, type RadioEntryData } from "@/wasm-communication/messages";
import { type RadioEntries, type RadioEntryData } from "@/wasm-communication/messages";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
export default defineComponent({
emits: ["update:selectedIndex"],
props: {
entries: { type: Array as PropType<RadioEntries>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
selectedIndex: { type: Number as PropType<number>, required: true },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
},
methods: {
handleEntryClick(radioEntryData: RadioEntryData) {
const index = this.entries.indexOf(radioEntryData);
this.$emit("update:selectedIndex", index);
// emits: ["update:selectedIndex"],
const dispatch = createEventDispatcher<{ selectedIndex: number }>();
radioEntryData.action?.();
},
},
components: {
IconLabel,
LayoutRow,
TextLabel,
},
});
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>
<template>
<LayoutRow class="radio-input" :class="{ disabled }">
<LayoutRow class="radio-input" classes={{ disabled }}>
{#each entries as entry, index (index)}
<button
:class="{ active: index === selectedIndex, disabled, 'sharp-right-corners': index === entries.length - 1 && sharpRightCorners }"
v-for="(entry, index) in entries"
:key="index"
@click="() => handleEntryClick(entry)"
:title="entry.tooltip"
:tabindex="index === selectedIndex ? -1 : 0"
:disabled="disabled"
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}
>
<IconLabel v-if="entry.icon" :icon="entry.icon" />
<TextLabel v-if="entry.label">{{ entry.label }}</TextLabel>
{#if entry.icon}
<IconLabel icon={entry.icon} />
{/if}
{#if entry.label}
<TextLabel>{entry.label}</TextLabel>
{/if}
</button>
</LayoutRow>
</template>
{/each}
</LayoutRow>
<style lang="scss">
.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;
<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);
&: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);
svg {
fill: var(--color-f-white);
}
}
&.active {
background: var(--color-8-uppergray);
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;
}
}
& + button {
margin-left: 1px;
.text-label {
margin: 0 4px;
overflow: hidden;
}
&:first-of-type {
border-radius: 2px 0 0 2px;
}
&:last-of-type {
border-radius: 0 2px 2px 0;
&.combined-before button:first-of-type,
&.combined-after button:last-of-type {
border-radius: 0;
}
}
.text-label {
margin: 0 4px;
overflow: hidden;
}
&.combined-before button:first-of-type,
&.combined-after button:last-of-type {
border-radius: 0;
}
}
</style>
@@ -1,96 +1,104 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { getContext } from "svelte";
import { type Color } from "@/wasm-communication/messages";
import { type Color } from "@/wasm-communication/messages";
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
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";
export default defineComponent({
inject: ["editor"],
props: {
primary: { type: Object as PropType<Color>, required: true },
secondary: { type: Object as PropType<Color>, required: true },
},
data() {
return {
primaryOpen: false,
secondaryOpen: false,
};
},
methods: {
clickPrimarySwatch() {
this.primaryOpen = true;
this.secondaryOpen = false;
},
clickSecondarySwatch() {
this.primaryOpen = false;
this.secondaryOpen = true;
},
primaryColorChanged(color: Color) {
this.editor.instance.updatePrimaryColor(color.red, color.green, color.blue, color.alpha);
},
secondaryColorChanged(color: Color) {
this.editor.instance.updateSecondaryColor(color.red, color.green, color.blue, color.alpha);
},
},
components: {
ColorPicker,
LayoutCol,
LayoutRow,
},
});
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>
<template>
<LayoutCol class="swatch-pair">
<LayoutRow class="primary swatch">
<button @click="() => clickPrimarySwatch()" :style="{ '--swatch-color': primary.toRgbaCSS() }" data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
<ColorPicker v-model:open="primaryOpen" :color="primary" @update:color="(color: Color) => primaryColorChanged(color)" :direction="'Right'" />
</LayoutRow>
<LayoutRow class="secondary swatch">
<button @click="() => clickSecondarySwatch()" :style="{ '--swatch-color': secondary.toRgbaCSS() }" data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
<ColorPicker v-model:open="secondaryOpen" :color="secondary" @update:color="(color: Color) => secondaryColorChanged(color)" :direction="'Right'" />
</LayoutRow>
</LayoutCol>
</template>
<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 }) => {
primary = 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 }) => {
secondary = detail;
secondaryColorChanged(detail);
}}
direction="Right"
/>
</LayoutRow>
</LayoutCol>
<style lang="scss">
.swatch-pair {
flex: 0 0 auto;
<style lang="scss" global>
.swatch-pair {
flex: 0 0 auto;
.swatch {
width: 28px;
height: 28px;
margin: 0 2px;
position: relative;
.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;
}
> 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;
}
.floating-menu {
top: 50%;
right: -2px;
}
&.primary {
margin-bottom: -8px;
z-index: 1;
&.primary {
margin-bottom: -8px;
z-index: 1;
}
}
}
}
</style>
@@ -1,76 +1,64 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
export default defineComponent({
emits: ["update:value", "commitText"],
props: {
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {
editing: false,
};
},
computed: {
inputValue: {
get() {
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
methods: {
onTextFocused() {
this.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)
onTextChanged() {
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
if (!this.editing) return;
// emits: ["update:value", "commitText"],
const dispatch = createEventDispatcher<{ commitText: string }>();
this.onCancelTextChange();
export let value: string;
export let label: string | undefined = undefined;
export let tooltip: string | undefined = undefined;
export let disabled = false;
// TODO: Find a less hacky way to do this
const inputElement = this.$refs.fieldInput as typeof FieldInput | undefined;
if (!inputElement) return;
this.$emit("commitText", inputElement.getInputElementValue());
let self: FieldInput;
let editing = false;
// Required if value is not changed by the parent component upon update:value event
inputElement.setInputElementValue(this.value);
},
onCancelTextChange() {
this.editing = false;
function onTextFocused() {
editing = true;
}
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
},
},
components: { FieldInput },
});
// 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", self.getValue());
// Required if value is not changed by the parent component upon update:value event
self.setInputElementValue(value);
}
function onCancelTextChange() {
editing = false;
self.unFocus();
}
export function focus() {
self.focus();
}
</script>
<template>
<FieldInput
:textarea="true"
class="text-area-input"
:class="{ 'has-label': label }"
:label="label"
:spellcheck="true"
:disabled="disabled"
:tooltip="tooltip"
v-model:value="inputValue"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
ref="fieldInput"
></FieldInput>
</template>
<FieldInput
class="text-area-input"
classes={{ "has-label": Boolean(label) }}
{value}
on:value
on:textFocused={onTextFocused}
on:textChanged={onTextChanged}
on:cancelTextChange={onCancelTextChange}
textarea={true}
spellcheck={true}
{label}
{disabled}
{tooltip}
bind:this={self}
/>
<style lang="scss"></style>
<style lang="scss" global>
</style>
@@ -1,103 +1,87 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { createEventDispatcher } from "svelte";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
import FieldInput from "@/components/widgets/inputs/FieldInput.svelte";
export default defineComponent({
emits: ["update:value", "commitText"],
props: {
// Label
label: { type: String as PropType<string>, required: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
placeholder: { type: String as PropType<string>, required: false },
// emits: ["update:value", "commitText"],
const dispatch = createEventDispatcher<{ commitText: string }>();
// Disabled
disabled: { type: Boolean as PropType<boolean>, default: false },
// 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;
// Value
value: { type: String as PropType<string>, required: true },
let self: FieldInput;
let editing = false;
// Styling
centered: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
editing: false,
};
},
computed: {
text: {
get() {
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
methods: {
onTextFocused() {
this.editing = true;
function onTextFocused() {
editing = true;
(this.$refs.fieldInput as typeof FieldInput | undefined)?.selectAllText(this.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)
onTextChanged() {
// The `unFocus()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
if (!this.editing) return;
self.selectAllText(value);
}
this.onCancelTextChange();
// 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;
// TODO: Find a less hacky way to do this
const inputElement = this.$refs.fieldInput as typeof FieldInput | undefined;
if (!inputElement) return;
this.$emit("commitText", inputElement.getInputElementValue());
onCancelTextChange();
// Required if value is not changed by the parent component upon update:value event
inputElement.setInputElementValue(this.value);
},
onCancelTextChange() {
this.editing = false;
// TODO: Find a less hacky way to do this
dispatch("commitText", self.getValue());
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
},
},
components: { FieldInput },
});
// Required if value is not changed by the parent component upon update:value event
self.setInputElementValue(value);
}
function onCancelTextChange() {
editing = false;
self.unFocus();
}
export function focus() {
self.focus();
}
</script>
<template>
<FieldInput
class="text-input"
:class="{ centered }"
v-model:value="text"
:label="label"
:spellcheck="true"
:disabled="disabled"
:tooltip="tooltip"
:placeholder="placeholder"
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined }"
:sharpRightCorners="sharpRightCorners"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
ref="fieldInput"
></FieldInput>
</template>
<FieldInput
class="text-input"
classes={{ centered }}
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
{value}
on:value
on:textFocused={onTextFocused}
on:textChanged={onTextChanged}
on:cancelTextChange={onCancelTextChange}
spellcheck={true}
{label}
{disabled}
{tooltip}
{placeholder}
{sharpRightCorners}
bind:this={self}
/>
<style lang="scss">
.text-input {
input {
text-align: left;
}
<style lang="scss" global>
.text-input {
input {
text-align: left;
}
&.centered {
input:not(:focus) {
text-align: center;
&.centered {
input:not(:focus) {
text-align: center;
}
}
}
}
</style>