Port NiimBlue label designer to Fichero D11s with local BLE protocol library
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { Barcode } from "$/fabric-object/barcode";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
selectedBarcode: Barcode;
|
||||
editRevision: number;
|
||||
valueUpdated: () => void;
|
||||
}
|
||||
|
||||
let { selectedBarcode, editRevision, valueUpdated }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="hidden" value={editRevision}>
|
||||
|
||||
<div class="input-group input-group-sm flex-nowrap">
|
||||
<span class="input-group-text" title={$tr("params.barcode.encoding")}><MdIcon icon="code" /></span>
|
||||
<select
|
||||
class="form-select"
|
||||
value={selectedBarcode.encoding}
|
||||
onchange={(e) => {
|
||||
selectedBarcode?.set("encoding", e.currentTarget.value ?? "EAN13");
|
||||
valueUpdated();
|
||||
}}>
|
||||
<option value="EAN13">EAN13</option>
|
||||
<option value="CODE128B">Code128 B</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm flex-nowrap">
|
||||
<span class="input-group-text" title={$tr("params.barcode.scale")}>
|
||||
<MdIcon icon="settings_ethernet" />
|
||||
</span>
|
||||
<input
|
||||
class="barcode-width form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
value={selectedBarcode.scaleFactor}
|
||||
oninput={(e) => {
|
||||
selectedBarcode?.set("scaleFactor", e.currentTarget.valueAsNumber ?? 1);
|
||||
valueUpdated();
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-sm {selectedBarcode.printText ? 'btn-secondary' : ''}"
|
||||
title={$tr("params.barcode.enable_caption")}
|
||||
onclick={() => {
|
||||
selectedBarcode?.set("printText", !selectedBarcode.printText);
|
||||
valueUpdated();
|
||||
}}>
|
||||
123
|
||||
</button>
|
||||
|
||||
<div class="input-group input-group-sm flex-nowrap">
|
||||
<span class="input-group-text" title={$tr("params.barcode.font_size")}>
|
||||
<MdIcon icon="format_size" />
|
||||
</span>
|
||||
<input
|
||||
class="barcode-width form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
value={selectedBarcode.fontSize}
|
||||
oninput={(e) => {
|
||||
selectedBarcode?.set("fontSize", e.currentTarget.valueAsNumber ?? 12);
|
||||
valueUpdated();
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{#if selectedBarcode.encoding === "EAN13"}
|
||||
<div class="input-group input-group-sm flex-nowrap">
|
||||
<span class="input-group-text" title={$tr("params.barcode.content")}><MdIcon icon="view_week" /></span>
|
||||
<input
|
||||
class="barcode-content form-control"
|
||||
maxlength="12"
|
||||
value={selectedBarcode.text}
|
||||
oninput={(e) => {
|
||||
selectedBarcode?.set("text", e.currentTarget.value);
|
||||
valueUpdated();
|
||||
}} />
|
||||
</div>
|
||||
{:else}
|
||||
<textarea
|
||||
class="barcode-content form-control"
|
||||
value={selectedBarcode.text}
|
||||
oninput={(e) => {
|
||||
selectedBarcode?.set("text", e.currentTarget.value);
|
||||
valueUpdated();
|
||||
}}></textarea>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
textarea.barcode-content {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
input.barcode-width {
|
||||
max-width: 64px;
|
||||
}
|
||||
</style>
|
||||
75
web/src/components/designer-controls/CsvControl.svelte
Normal file
75
web/src/components/designer-controls/CsvControl.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { tr } from "$/utils/i18n";
|
||||
import { csvParse } from "d3-dsv";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import { type CsvParams } from "$/types";
|
||||
import { csvData } from "$/stores";
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
onPlaceholderPicked: (name: string) => void;
|
||||
}
|
||||
|
||||
let { enabled = $bindable(), onPlaceholderPicked }: Props = $props();
|
||||
|
||||
let placeholders = $state<string[]>([]);
|
||||
let rows = $state<number>(0);
|
||||
|
||||
const parse = (csv: CsvParams) => {
|
||||
const result = csvParse(csv.data);
|
||||
placeholders = result.columns;
|
||||
rows = result.length;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
parse($csvData);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm btn-{enabled ? 'warning' : 'secondary'}"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
title={$tr("params.csv.title")}>
|
||||
<MdIcon icon="dataset" />
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<h6 class="dropdown-header">{$tr("params.csv.title")}</h6>
|
||||
<div class="p-3 text-body-secondary">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="enabled" bind:checked={enabled} />
|
||||
<label class="form-check-label" for="enabled">{$tr("params.csv.enabled")}</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
{$tr("params.csv.tip")}
|
||||
</div>
|
||||
|
||||
<textarea class="dsv form-control my-3" bind:value={$csvData.data} oninput={() => (enabled = true)}></textarea>
|
||||
|
||||
<div class="placeholders pt-1">
|
||||
{$tr("params.csv.rowsfound")} <strong>{rows}</strong>
|
||||
</div>
|
||||
<div class="placeholders pt-1">
|
||||
{$tr("params.csv.placeholders")}
|
||||
{#each placeholders as p (p)}
|
||||
<button class="btn btn-sm btn-outline-info px-1 py-0" onclick={() => onPlaceholderPicked(p)}
|
||||
>{`{${p}}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
width: 100vw;
|
||||
max-width: 450px;
|
||||
}
|
||||
textarea.dsv {
|
||||
font-family: monospace;
|
||||
min-height: 240px;
|
||||
}
|
||||
</style>
|
||||
23
web/src/components/designer-controls/DpiSelector.svelte
Normal file
23
web/src/components/designer-controls/DpiSelector.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { tr } from "$/utils/i18n";
|
||||
interface Props {
|
||||
value: number;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<span class="input-group-text">{$tr("params.label.head_density")}</span>
|
||||
|
||||
<select class="form-select" bind:value>
|
||||
<option value={8}>203dpi</option>
|
||||
<option value={11.81}>300dpi</option>
|
||||
</select>
|
||||
|
||||
<input class="form-control" type="number" min="1" bind:value />
|
||||
|
||||
<span class="input-group-text cursor-help" title={$tr("params.label.head_density.help")}>
|
||||
{$tr("params.label.dpmm")}
|
||||
</span>
|
||||
</div>
|
||||
121
web/src/components/designer-controls/FontFamilyPicker.svelte
Normal file
121
web/src/components/designer-controls/FontFamilyPicker.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { OBJECT_DEFAULTS_TEXT } from "$/defaults";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import { Toasts } from "$/utils/toasts";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import { LocalStoragePersistence } from "$/utils/persistence";
|
||||
import { fontCache, userFonts } from "$/stores";
|
||||
import FontsMenu from "$/components/designer-controls/FontsMenu.svelte";
|
||||
|
||||
interface Props {
|
||||
editRevision?: number;
|
||||
value: string;
|
||||
valueUpdated: (v: string) => void;
|
||||
}
|
||||
|
||||
let { value, valueUpdated, editRevision }: Props = $props();
|
||||
|
||||
let fontQuerySupported = typeof queryLocalFonts !== "undefined";
|
||||
|
||||
const getSystemFonts = async () => {
|
||||
try {
|
||||
const fonts = await queryLocalFonts();
|
||||
const fontListSorted = [OBJECT_DEFAULTS_TEXT.fontFamily, ...new Set(fonts.map((f: FontData) => f.family))].sort();
|
||||
fontCache.update(() => fontListSorted);
|
||||
LocalStoragePersistence.saveCachedFonts(fontListSorted);
|
||||
} catch (e) {
|
||||
Toasts.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
let stored = LocalStoragePersistence.loadCachedFonts();
|
||||
if (stored.length > 0) {
|
||||
const uniqueFonts = new Set([OBJECT_DEFAULTS_TEXT.fontFamily, ...stored]);
|
||||
fontCache.update(() => [...uniqueFonts].sort());
|
||||
}
|
||||
} catch (e) {
|
||||
Toasts.error(e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm font-family-picker">
|
||||
<span class="input-group-text" title={$tr("params.text.font_family")}>
|
||||
<MdIcon icon="text_format" />
|
||||
</span>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="form-control font-family-input"
|
||||
data-ver={editRevision}
|
||||
{value}
|
||||
oninput={(e) => valueUpdated(e.currentTarget.value)} />
|
||||
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
{#if $fontCache.length > 0 || $userFonts.length > 0}
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"></button>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
{#if $userFonts.length > 0}
|
||||
<h6 class="dropdown-header">{$tr("params.text.user_fonts")}</h6>
|
||||
{#each $userFonts as font (font.family)}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
style="font-family: {font.family}"
|
||||
type="button"
|
||||
onclick={() => valueUpdated(font.family)}>
|
||||
{font.family}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if $fontCache.length > 0}
|
||||
<h6 class="dropdown-header">{$tr("params.text.system_fonts")}</h6>
|
||||
{#each $fontCache as family (family)}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
style="font-family: {family}"
|
||||
type="button"
|
||||
onclick={() => valueUpdated(family)}>
|
||||
{family}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if fontQuerySupported}
|
||||
<button class="btn {$fontCache.length <= 1 ? 'btn-primary pulse' : 'btn-outline-secondary'}" onclick={getSystemFonts} title={$tr("params.text.fetch_fonts")}>
|
||||
<MdIcon icon="refresh" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<FontsMenu />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.font-family-picker {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.font-family-input {
|
||||
width: 14em;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.5s ease-in-out 3;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
99
web/src/components/designer-controls/FontsMenu.svelte
Normal file
99
web/src/components/designer-controls/FontsMenu.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import AppModal from "$/components/basic/AppModal.svelte";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import { userFonts } from "$/stores";
|
||||
import { FileUtils } from "$/utils/file_utils";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import { LocalStoragePersistence } from "$/utils/persistence";
|
||||
import { Toasts } from "$/utils/toasts";
|
||||
|
||||
let show = $state<boolean>(false);
|
||||
let usedSpace = $state<number>(0);
|
||||
let selectExt = $state<"ttf" | "woff2">("ttf");
|
||||
let overrideFamily = $state<string>("");
|
||||
|
||||
const calcUsedSpace = () => {
|
||||
usedSpace = LocalStoragePersistence.usedSpace();
|
||||
};
|
||||
|
||||
const browseFont = async () => {
|
||||
const result = await FileUtils.pickAndReadBinaryFile(selectExt);
|
||||
|
||||
let fontName = result.name.split(".")[0];
|
||||
const mime = `text/${selectExt}`;
|
||||
|
||||
if (overrideFamily.trim() !== "") {
|
||||
fontName = overrideFamily.trim();
|
||||
}
|
||||
|
||||
if ($userFonts.some((e) => e.family == fontName)) {
|
||||
Toasts.error(`${fontName} already loaded`);
|
||||
return;
|
||||
}
|
||||
|
||||
const compressed = await FileUtils.compressData(result.data);
|
||||
const b64data = await FileUtils.base64buf(compressed);
|
||||
|
||||
userFonts.update((prev) => [...prev, { gzippedDataB64: b64data, family: fontName, mimeType: mime }]);
|
||||
|
||||
calcUsedSpace();
|
||||
overrideFamily = "";
|
||||
};
|
||||
|
||||
const removeFont = (family: string) => {
|
||||
userFonts.update((prev) => prev.filter((e) => e.family !== family));
|
||||
calcUsedSpace();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (show) calcUsedSpace();
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={() => {
|
||||
show = true;
|
||||
}}>
|
||||
<MdIcon icon="settings" />
|
||||
</button>
|
||||
|
||||
{#if show}
|
||||
<AppModal title={$tr("fonts.title")} bind:show>
|
||||
<div class="mb-1">
|
||||
{#each $userFonts as font (font.family)}
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<span class="input-group-text fs-5" style="font-family: {font.family}">{font.family}</span>
|
||||
<button class="btn btn-sm btn-danger" onclick={() => removeFont(font.family)}>
|
||||
<MdIcon icon="delete" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
👀
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">{$tr("fonts.add")}</span>
|
||||
|
||||
<select class="form-select" bind:value={selectExt}>
|
||||
<option value="ttf">ttf</option>
|
||||
<option value="woff2">woff2</option>
|
||||
</select>
|
||||
|
||||
<input type="text" class="form-control w-25" placeholder={$tr("fonts.title_override")} bind:value={overrideFamily} />
|
||||
|
||||
<button class="btn btn-sm btn-secondary" onclick={browseFont}>{$tr("fonts.browse")}</button>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="text-secondary">
|
||||
{usedSpace}
|
||||
{$tr("params.saved_labels.kb_used")} |
|
||||
<a class="text-secondary" href="https://fonts.google.com">{$tr("fonts.gfonts")}</a>
|
||||
</div>
|
||||
{/snippet}
|
||||
</AppModal>
|
||||
{/if}
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import * as fabric from "fabric";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import { appConfig } from "$/stores";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import ObjectPositionControls from "$/components/designer-controls/ObjectPositionControls.svelte";
|
||||
|
||||
|
||||
interface Props {
|
||||
selectedObject: fabric.FabricObject;
|
||||
editRevision: number;
|
||||
valueUpdated: () => void;
|
||||
}
|
||||
|
||||
let { selectedObject, editRevision, valueUpdated }: Props = $props();
|
||||
|
||||
const putToCenterV = () => {
|
||||
selectedObject.canvas!.centerObjectV(selectedObject);
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const putToCenterH = () => {
|
||||
selectedObject.canvas!.centerObjectH(selectedObject);
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const bringTo = (to: "top" | "bottom") => {
|
||||
if (to === "top") {
|
||||
selectedObject.canvas?.bringObjectToFront(selectedObject);
|
||||
} else if (to === "bottom") {
|
||||
selectedObject.canvas?.sendObjectToBack(selectedObject);
|
||||
}
|
||||
};
|
||||
|
||||
const fit = () => {
|
||||
const imageRatio = selectedObject.width / selectedObject.height;
|
||||
const canvasRatio = selectedObject.canvas!.width / selectedObject.canvas!.height;
|
||||
|
||||
if ($appConfig.fitMode === "ratio_min") {
|
||||
if (imageRatio > canvasRatio) {
|
||||
selectedObject.scaleToWidth(selectedObject.canvas!.width);
|
||||
} else {
|
||||
selectedObject.scaleToHeight(selectedObject.canvas!.height);
|
||||
}
|
||||
selectedObject.canvas!.centerObject(selectedObject);
|
||||
} else if ($appConfig.fitMode === "ratio_max") {
|
||||
if (imageRatio > canvasRatio) {
|
||||
selectedObject.scaleToHeight(selectedObject.canvas!.height);
|
||||
} else {
|
||||
selectedObject.scaleToWidth(selectedObject.canvas!.width);
|
||||
}
|
||||
selectedObject.canvas!.centerObject(selectedObject);
|
||||
} else {
|
||||
selectedObject.set({
|
||||
left: 0,
|
||||
top: 0,
|
||||
scaleX: selectedObject.canvas!.width / selectedObject.width,
|
||||
scaleY: selectedObject.canvas!.height / selectedObject.height,
|
||||
});
|
||||
}
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const fitModeChanged = (e: Event & { currentTarget: HTMLSelectElement }) => {
|
||||
const fitMode = e.currentTarget.value as "stretch" | "ratio_min" | "ratio_max";
|
||||
appConfig.update((v) => ({ ...v, fitMode: fitMode }));
|
||||
};
|
||||
</script>
|
||||
|
||||
<input type="hidden" value={editRevision}>
|
||||
|
||||
<button class="btn btn-sm btn-secondary" onclick={putToCenterV} title={$tr("params.generic.center.vertical")}>
|
||||
<MdIcon icon="vertical_distribute" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick={putToCenterH} title={$tr("params.generic.center.horizontal")}>
|
||||
<MdIcon icon="horizontal_distribute" />
|
||||
</button>
|
||||
|
||||
<ObjectPositionControls {selectedObject} />
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
title={$tr("params.generic.arrange")}>
|
||||
<MdIcon icon="segment" />
|
||||
</button>
|
||||
<div class="dropdown-menu arrangement p-2">
|
||||
<button class="btn btn-sm" onclick={() => bringTo("top")}>
|
||||
{$tr("params.generic.arrange.top")}
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick={() => bringTo("bottom")}>
|
||||
{$tr("params.generic.arrange.bottom")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedObject instanceof fabric.FabricImage}
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-secondary" onclick={fit} title={$tr("params.generic.fit")}>
|
||||
<MdIcon icon="fit_screen" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Toggle"
|
||||
type="button"
|
||||
class="btn btn-secondary dropdown-toggle dropdown-toggle-split px-1"
|
||||
data-bs-toggle="dropdown"></button>
|
||||
<div class="dropdown-menu p-1">
|
||||
<select class="form-select form-select-sm" value={$appConfig.fitMode ?? "stretch"} onchange={fitModeChanged}>
|
||||
<option value="stretch">{$tr("params.generic.fit.mode.stretch")}</option>
|
||||
<option value="ratio_min">{$tr("params.generic.fit.mode.ratio_min")}</option>
|
||||
<option value="ratio_max">{$tr("params.generic.fit.mode.ratio_max")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dropdown-menu.arrangement {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
152
web/src/components/designer-controls/IconPicker.svelte
Normal file
152
web/src/components/designer-controls/IconPicker.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import { iconCodepoints, type MaterialIcon } from "$/styles/mdi_icons";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import { appConfig, userIcons } from "$/stores";
|
||||
import { FileUtils } from "$/utils/file_utils";
|
||||
import { Toasts } from "$/utils/toasts";
|
||||
|
||||
interface Props {
|
||||
onSubmit: (i: MaterialIcon) => void;
|
||||
onSubmitSvg: (i: string) => void;
|
||||
}
|
||||
|
||||
let { onSubmit, onSubmitSvg }: Props = $props();
|
||||
|
||||
let iconNames = $state<MaterialIcon[]>([]);
|
||||
let search = $state<string>("");
|
||||
let deleteMode = $state<boolean>(false);
|
||||
let dropdown: HTMLDivElement;
|
||||
|
||||
const onShow = () => {
|
||||
if (iconNames.length === 0) {
|
||||
iconNames = Object.keys(iconCodepoints) as MaterialIcon[];
|
||||
}
|
||||
};
|
||||
|
||||
const addOwn = async () => {
|
||||
try {
|
||||
let counter = 0;
|
||||
const xmls = await FileUtils.pickAndReadTextFile("svg", true);
|
||||
const iconsToAdd = xmls.map((xml) => ({
|
||||
name: `i_${FileUtils.timestampFloat()}_${counter++}`,
|
||||
data: xml,
|
||||
}));
|
||||
|
||||
userIcons.update((prev) => [...prev, ...iconsToAdd]);
|
||||
} catch (e) {
|
||||
Toasts.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const svgClicked = (name: string, data: string) => {
|
||||
if (deleteMode) {
|
||||
userIcons.update((prev) => prev.filter((e) => e.name !== name));
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmitSvg(data);
|
||||
};
|
||||
|
||||
const iconClicked = (i: MaterialIcon) => {
|
||||
if (deleteMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(i);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
dropdown?.addEventListener("show.bs.dropdown", onShow);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
dropdown?.removeEventListener("show.bs.dropdown", onShow);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown" bind:this={dropdown}>
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<MdIcon icon="emoji_emotions" />
|
||||
<MdIcon icon="add" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<h6 class="dropdown-header">{$tr("editor.iconpicker.title")}</h6>
|
||||
<div class="p-3">
|
||||
<input
|
||||
disabled={$appConfig.iconListMode === "user"}
|
||||
type="text"
|
||||
class="form-control mb-1"
|
||||
placeholder={$tr("editor.iconpicker.search")}
|
||||
bind:value={search} />
|
||||
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<span class="input-group-text">{$tr("editor.iconpicker.show")}</span>
|
||||
<select class="form-select form-select-sm" bind:value={$appConfig.iconListMode}>
|
||||
<option value="both">{$tr("editor.iconpicker.show.both")}</option>
|
||||
<option value="user">{$tr("editor.iconpicker.show.user")}</option>
|
||||
<option value="pack">{$tr("editor.iconpicker.show.pack")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="icons mb-1">
|
||||
{#if $appConfig.iconListMode === "both" || $appConfig.iconListMode === "user"}
|
||||
{#each $userIcons as { name, data } (name)}
|
||||
<button
|
||||
class="btn {deleteMode ? 'btn-danger' : 'btn-light'} me-1 mb-1 user-icon"
|
||||
onclick={() => svgClicked(name, data)}>
|
||||
<img src="data:image/svg+xml;base64,{FileUtils.base64str(data)}" alt="user-svg" />
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if $appConfig.iconListMode === "both" || $appConfig.iconListMode === "pack"}
|
||||
{#each iconNames as name (name)}
|
||||
{#if !search || name.includes(search.toLowerCase())}
|
||||
<button class="btn me-1" title={name} onclick={() => iconClicked(name)}>
|
||||
<MdIcon icon={name} />
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<button class="btn btn-outline-secondary" onclick={addOwn}>
|
||||
<MdIcon icon="add" />
|
||||
|
||||
{$tr("editor.iconpicker.add")}
|
||||
</button>
|
||||
<button
|
||||
class="btn {deleteMode ? 'btn-danger' : 'btn-outline-secondary'}"
|
||||
onclick={() => (deleteMode = !deleteMode)}>
|
||||
<MdIcon icon="delete" />
|
||||
{$tr("editor.iconpicker.delete_mode")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://fonts.google.com/icons?icon.set=Material+Icons&icon.style=Filled"
|
||||
target="_blank"
|
||||
class="text-secondary">
|
||||
{$tr("editor.iconpicker.mdi_link_title")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
width: 100vw;
|
||||
max-width: 450px;
|
||||
}
|
||||
.icons {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.user-icon img {
|
||||
width: 24px;
|
||||
}
|
||||
</style>
|
||||
122
web/src/components/designer-controls/LabelPresetsBrowser.svelte
Normal file
122
web/src/components/designer-controls/LabelPresetsBrowser.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import type { LabelPreset } from "$/types";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
onItemSelected: (index: number) => void;
|
||||
onItemDelete: (index: number) => void;
|
||||
presets: LabelPreset[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = "", onItemDelete, onItemSelected, presets }: Props = $props();
|
||||
let deleteIndex = $state<number>(-1);
|
||||
|
||||
const scaleDimensions = (preset: LabelPreset): { width: number; height: number } => {
|
||||
const scaleFactor = Math.min(100 / preset.width, 100 / preset.height);
|
||||
return {
|
||||
width: Math.round(preset.width * scaleFactor),
|
||||
height: Math.round(preset.height * scaleFactor),
|
||||
};
|
||||
};
|
||||
|
||||
const deleteConfirmed = (e: MouseEvent, idx: number) => {
|
||||
e.stopPropagation();
|
||||
deleteIndex = -1;
|
||||
onItemDelete(idx);
|
||||
};
|
||||
|
||||
const deleteRejected = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
deleteIndex = -1;
|
||||
};
|
||||
|
||||
const deleteRequested = (e: MouseEvent, idx: number) => {
|
||||
e.stopPropagation();
|
||||
deleteIndex = idx;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="preset-browser overflow-y-auto border d-flex p-2 gap-1 flex-wrap {className}">
|
||||
<!-- fixme: key -->
|
||||
{#each presets as item, idx (item)}
|
||||
<div
|
||||
role="button"
|
||||
class="btn p-0 card-wrapper d-flex justify-content-center align-items-center"
|
||||
tabindex="0"
|
||||
onkeydown={() => onItemSelected(idx)}
|
||||
onclick={() => onItemSelected(idx)}>
|
||||
<div
|
||||
class="card print-start-{item.printDirection} d-flex justify-content-center align-items-center"
|
||||
style="width: {scaleDimensions(item).width}%; height: {scaleDimensions(item).height}%;">
|
||||
<div class="remove d-flex">
|
||||
{#if deleteIndex === idx}
|
||||
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteConfirmed(e, idx)}>
|
||||
<MdIcon icon="delete" />
|
||||
</button>
|
||||
<button class="remove btn text-success" onclick={(e) => deleteRejected(e)}>
|
||||
<MdIcon icon="close" />
|
||||
</button>
|
||||
{:else}
|
||||
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteRequested(e, idx)}>
|
||||
<MdIcon icon="delete" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="label p-1">
|
||||
{#if item.title}
|
||||
{item.title}
|
||||
{:else}
|
||||
{item.width}x{item.height}{#if item.unit === "mm"}{$tr("params.label.mm")}{:else if item.unit === "px"}{$tr(
|
||||
"params.label.px",
|
||||
)}{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preset-browser {
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card > .remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.card > .remove > button {
|
||||
padding: 0;
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.card > .label {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
color: black;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card.print-start-left {
|
||||
border-left: 2px solid #ff4646;
|
||||
}
|
||||
.card.print-start-top {
|
||||
border-top: 2px solid #ff4646;
|
||||
}
|
||||
</style>
|
||||
509
web/src/components/designer-controls/LabelPropsEditor.svelte
Normal file
509
web/src/components/designer-controls/LabelPropsEditor.svelte
Normal file
@@ -0,0 +1,509 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
LabelPresetSchema,
|
||||
type LabelPreset,
|
||||
type LabelProps,
|
||||
type LabelShape,
|
||||
type LabelSplit,
|
||||
type LabelUnit,
|
||||
type MirrorType,
|
||||
type TailPosition,
|
||||
} from "$/types";
|
||||
import LabelPresetsBrowser from "$/components/designer-controls/LabelPresetsBrowser.svelte";
|
||||
import { printerMeta } from "$/stores";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import { DEFAULT_LABEL_PRESETS } from "$/defaults";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { LocalStoragePersistence } from "$/utils/persistence";
|
||||
import type { PrintDirection } from "$/lib/fichero";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import { Toasts } from "$/utils/toasts";
|
||||
import { FileUtils } from "$/utils/file_utils";
|
||||
import { z } from "zod";
|
||||
import DpiSelector from "$/components/designer-controls/DpiSelector.svelte";
|
||||
|
||||
interface Props {
|
||||
labelProps: LabelProps;
|
||||
onChange: (newProps: LabelProps) => void;
|
||||
}
|
||||
|
||||
let { labelProps, onChange }: Props = $props();
|
||||
|
||||
const tailPositions: TailPosition[] = ["right", "bottom", "left", "top"];
|
||||
const printDirections: PrintDirection[] = ["left", "top"];
|
||||
const labelShapes: LabelShape[] = ["rect", "rounded_rect", "circle"];
|
||||
const labelSplits: LabelSplit[] = ["none", "vertical", "horizontal"];
|
||||
const mirrorTypes: MirrorType[] = ["none", "flip", "copy"];
|
||||
|
||||
let labelPresets = $state<LabelPreset[]>(DEFAULT_LABEL_PRESETS);
|
||||
|
||||
let title = $state<string | undefined>("");
|
||||
let prevUnit: LabelUnit = "mm";
|
||||
let unit = $state<LabelUnit>("mm");
|
||||
let dpmm = $state<number>(8);
|
||||
let width = $state<number>(0);
|
||||
let height = $state<number>(0);
|
||||
let printDirection = $state<PrintDirection>("left");
|
||||
let shape = $state<LabelShape>("rect");
|
||||
let split = $state<LabelSplit>("none");
|
||||
let splitParts = $state<number>(2);
|
||||
let tailLength = $state<number>(0);
|
||||
let tailPos = $state<TailPosition>("right");
|
||||
let mirror = $state<MirrorType>("none");
|
||||
|
||||
let error = $derived.by<string>(() => {
|
||||
let error = "";
|
||||
|
||||
const headSize = labelProps.printDirection == "left" ? labelProps.size.height : labelProps.size.width;
|
||||
if ($printerMeta !== undefined) {
|
||||
if (headSize > $printerMeta.printheadPixels) {
|
||||
error += $tr("params.label.warning.width") + " ";
|
||||
error += `(${headSize} > ${$printerMeta.printheadPixels})`;
|
||||
error += "\n";
|
||||
}
|
||||
|
||||
if ($printerMeta.printDirection !== labelProps.printDirection) {
|
||||
error += $tr("params.label.warning.direction") + " ";
|
||||
if ($printerMeta.printDirection == "left") {
|
||||
error += $tr("params.label.direction.left");
|
||||
} else {
|
||||
error += $tr("params.label.direction.top");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (headSize % 8 !== 0) {
|
||||
error += $tr("params.label.warning.div8");
|
||||
}
|
||||
|
||||
return error;
|
||||
});
|
||||
|
||||
const onApply = () => {
|
||||
let newWidth = width;
|
||||
let newHeight = height;
|
||||
let newTailLength = tailLength;
|
||||
|
||||
// mm to px
|
||||
if (unit === "mm") {
|
||||
newWidth *= dpmm;
|
||||
newHeight *= dpmm;
|
||||
newTailLength *= dpmm;
|
||||
}
|
||||
|
||||
// limit min size
|
||||
newWidth = newWidth < dpmm ? dpmm : newWidth;
|
||||
newHeight = newHeight < dpmm ? dpmm : newHeight;
|
||||
|
||||
// width must me multiple of 8
|
||||
if (printDirection === "left") {
|
||||
newHeight -= newHeight % 8;
|
||||
} else {
|
||||
newWidth -= newWidth % 8;
|
||||
}
|
||||
|
||||
onChange({
|
||||
printDirection: printDirection,
|
||||
size: {
|
||||
width: Math.floor(newWidth),
|
||||
height: Math.floor(newHeight),
|
||||
},
|
||||
shape,
|
||||
split,
|
||||
splitParts,
|
||||
tailPos,
|
||||
tailLength: Math.floor(newTailLength),
|
||||
mirror,
|
||||
});
|
||||
};
|
||||
|
||||
const onLabelPresetSelected = (index: number) => {
|
||||
const preset = labelPresets[index];
|
||||
|
||||
if (preset !== undefined) {
|
||||
dpmm = preset.dpmm;
|
||||
prevUnit = preset.unit;
|
||||
unit = preset.unit;
|
||||
printDirection = preset.printDirection;
|
||||
width = preset.width;
|
||||
height = preset.height;
|
||||
title = preset.title ?? "";
|
||||
shape = preset.shape ?? "rect";
|
||||
split = preset.split ?? "none";
|
||||
splitParts = preset.splitParts ?? 2;
|
||||
tailPos = preset.tailPos ?? "right";
|
||||
tailLength = preset.tailLength ?? 0;
|
||||
mirror = preset.mirror ?? "none";
|
||||
}
|
||||
|
||||
onApply();
|
||||
};
|
||||
|
||||
const onLabelPresetDelete = (idx: number) => {
|
||||
const result = [...labelPresets];
|
||||
result.splice(idx, 1);
|
||||
labelPresets = result;
|
||||
LocalStoragePersistence.saveLabelPresets(labelPresets);
|
||||
};
|
||||
|
||||
const onLabelPresetAdd = () => {
|
||||
const newPreset: LabelPreset = {
|
||||
dpmm,
|
||||
printDirection,
|
||||
unit,
|
||||
width,
|
||||
height,
|
||||
title,
|
||||
shape,
|
||||
split,
|
||||
splitParts,
|
||||
tailPos,
|
||||
tailLength,
|
||||
mirror,
|
||||
};
|
||||
const newPresets = [...labelPresets, newPreset];
|
||||
try {
|
||||
LocalStoragePersistence.saveLabelPresets(newPresets);
|
||||
labelPresets = newPresets;
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Presets save error:");
|
||||
}
|
||||
};
|
||||
|
||||
const onFlip = () => {
|
||||
let widthTmp = width;
|
||||
width = height;
|
||||
height = widthTmp;
|
||||
printDirection = printDirection === "top" ? "left" : "top";
|
||||
};
|
||||
|
||||
const onUnitChange = () => {
|
||||
if (prevUnit === "mm" && unit === "px") {
|
||||
width = Math.floor(width * dpmm);
|
||||
height = Math.floor(height * dpmm);
|
||||
tailLength = Math.floor(tailLength * dpmm);
|
||||
} else if (prevUnit === "px" && unit === "mm") {
|
||||
width = Math.floor(width / dpmm);
|
||||
height = Math.floor(height / dpmm);
|
||||
tailLength = Math.floor(tailLength / dpmm);
|
||||
}
|
||||
prevUnit = unit;
|
||||
};
|
||||
|
||||
const fillWithCurrentParams = () => {
|
||||
prevUnit = "px";
|
||||
width = labelProps.size.width;
|
||||
height = labelProps.size.height;
|
||||
printDirection = labelProps.printDirection;
|
||||
shape = labelProps.shape ?? "rect";
|
||||
split = labelProps.split ?? "none";
|
||||
splitParts = labelProps.splitParts ?? 2;
|
||||
tailPos = labelProps.tailPos ?? "right";
|
||||
tailLength = labelProps.tailLength ?? 0;
|
||||
mirror = labelProps.mirror ?? "none";
|
||||
onUnitChange();
|
||||
};
|
||||
|
||||
const onImportClicked = async () => {
|
||||
const contents = await FileUtils.pickAndReadSingleTextFile("json");
|
||||
const rawData = JSON.parse(contents);
|
||||
|
||||
if (!confirm($tr("params.label.warning.import"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const presets = z.array(LabelPresetSchema).parse(rawData);
|
||||
LocalStoragePersistence.saveLabelPresets(presets);
|
||||
labelPresets = presets;
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Presets load error:");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportClicked = () => {
|
||||
try {
|
||||
FileUtils.saveLabelPresetsAsJson(labelPresets);
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Presets save error:");
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const defaultPreset: LabelPreset = DEFAULT_LABEL_PRESETS[0];
|
||||
width = defaultPreset.width;
|
||||
height = defaultPreset.height;
|
||||
prevUnit = defaultPreset.unit;
|
||||
unit = defaultPreset.unit;
|
||||
printDirection = defaultPreset.printDirection;
|
||||
shape = defaultPreset.shape ?? "rect";
|
||||
split = defaultPreset.split ?? "none";
|
||||
tailPos = defaultPreset.tailPos ?? "right";
|
||||
tailLength = defaultPreset.tailLength ?? 0;
|
||||
mirror = defaultPreset.mirror ?? "none";
|
||||
|
||||
try {
|
||||
const savedPresets: LabelPreset[] | null = LocalStoragePersistence.loadLabelPresets();
|
||||
if (savedPresets !== null) {
|
||||
labelPresets = savedPresets;
|
||||
}
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Presets load error:");
|
||||
}
|
||||
|
||||
tick().then(() => fillWithCurrentParams());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (shape === "circle" && split !== "none") split = "none";
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (split === "none" || tailLength < 0) tailLength = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (mirror === "flip" && splitParts !== 2) mirror = "copy";
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<MdIcon icon="settings" />
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<h6 class="dropdown-header">{$tr("params.label.menu_title")}</h6>
|
||||
|
||||
<div class="px-3">
|
||||
<div class="p-1">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick={onImportClicked}>
|
||||
<MdIcon icon="data_object" />
|
||||
{$tr("params.label.import")}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick={onExportClicked}>
|
||||
<MdIcon icon="data_object" />
|
||||
{$tr("params.label.export")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-3 {error ? 'cursor-help text-warning' : 'text-secondary'}" title={error}>
|
||||
{$tr("params.label.current")}
|
||||
{labelProps.size.width}x{labelProps.size.height}
|
||||
{$tr("params.label.px")}
|
||||
{#if labelProps.printDirection === "top"}
|
||||
({$tr("params.label.direction")} {$tr("params.label.direction.top")})
|
||||
{:else if labelProps.printDirection === "left"}
|
||||
({$tr("params.label.direction")} {$tr("params.label.direction.left")})
|
||||
{/if}
|
||||
<button class="btn btn-sm" onclick={fillWithCurrentParams}><MdIcon icon="arrow_downward" /></button>
|
||||
</div>
|
||||
|
||||
<LabelPresetsBrowser
|
||||
class="mb-1"
|
||||
presets={labelPresets}
|
||||
onItemSelected={onLabelPresetSelected}
|
||||
onItemDelete={onLabelPresetDelete} />
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<span class="input-group-text">{$tr("params.label.size")}</span>
|
||||
<input class="form-control" type="number" min="1" step={unit === "px" ? 8 : 1} bind:value={width} />
|
||||
<button class="btn btn-sm btn-secondary" onclick={onFlip}><MdIcon icon="swap_horiz" /></button>
|
||||
<input class="form-control" type="number" min="1" step={unit === "px" ? 8 : 1} bind:value={height} />
|
||||
<select class="form-select" bind:value={unit} onchange={onUnitChange}>
|
||||
<option value="mm"> {$tr("params.label.mm")}</option>
|
||||
<option value="px"> {$tr("params.label.px")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if unit !== "px"}
|
||||
<DpiSelector bind:value={dpmm} />
|
||||
{/if}
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm print-dir-switch mb-2" role="group">
|
||||
<span class="input-group-text w-100">{$tr("params.label.direction")}</span>
|
||||
{#each printDirections as v (v)}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="print-dir"
|
||||
id="print-dir-{v}"
|
||||
autocomplete="off"
|
||||
bind:group={printDirection}
|
||||
value={v} />
|
||||
<label class="btn btn-outline-secondary px-3" for="print-dir-{v}">
|
||||
<div class="svg-icon"></div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm label-shape-switch mb-2" role="group">
|
||||
<span class="input-group-text w-100">{$tr("params.label.shape")}</span>
|
||||
{#each labelShapes as v (v)}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="label-shape"
|
||||
id="label-shape-{v}"
|
||||
autocomplete="off"
|
||||
bind:group={shape}
|
||||
value={v} />
|
||||
<label class="btn btn-outline-secondary px-3" for="label-shape-{v}">
|
||||
<div class="svg-icon"></div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if shape !== "circle"}
|
||||
<div class="input-group flex-nowrap input-group-sm label-split-switch mb-2" role="group">
|
||||
<span class="input-group-text w-100">{$tr("params.label.split")}</span>
|
||||
{#each labelSplits as v (v)}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="label-split"
|
||||
id="label-split-{v}"
|
||||
autocomplete="off"
|
||||
bind:group={split}
|
||||
value={v} />
|
||||
<label class="btn btn-outline-secondary px-3" for="label-split-{v}">
|
||||
<div class="svg-icon"></div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if split !== "none"}
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<span class="input-group-text">{$tr("params.label.split.count")}</span>
|
||||
<input class="form-control" type="number" min="1" bind:value={splitParts} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if split !== "none"}
|
||||
<div class="input-group flex-nowrap input-group-sm mirror-switch mb-2" role="group">
|
||||
<span class="input-group-text w-100">{$tr("params.label.mirror")}</span>
|
||||
{#each mirrorTypes as v (v)}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="mirror"
|
||||
id="mirror-{v}"
|
||||
autocomplete="off"
|
||||
bind:group={mirror}
|
||||
value={v} />
|
||||
<label class="btn btn-outline-secondary px-3" for="mirror-{v}">
|
||||
<div class="svg-icon"></div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm tail-pos-switch mb-2" role="group">
|
||||
<span class="input-group-text w-100">{$tr("params.label.tail.position")}</span>
|
||||
{#each tailPositions as v (v)}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="tail-pos"
|
||||
id="tail-{v}"
|
||||
autocomplete="off"
|
||||
bind:group={tailPos}
|
||||
value={v} />
|
||||
<label class="btn btn-outline-secondary px-3" for="tail-{v}">
|
||||
<div class="svg-icon"></div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<span class="input-group-text">{$tr("params.label.tail.length")}</span>
|
||||
<input class="form-control" type="number" min="1" bind:value={tailLength} />
|
||||
<span class="input-group-text">
|
||||
{#if unit === "mm"}{$tr("params.label.mm")}{/if}
|
||||
{#if unit === "px"}{$tr("params.label.px")}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<span class="input-group-text">{$tr("params.label.label_title")}</span>
|
||||
<input class="form-control" type="text" bind:value={title} />
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<button class="btn btn-sm btn-secondary" onclick={onLabelPresetAdd}>
|
||||
{$tr("params.label.save_template")}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" onclick={onApply}>{$tr("params.label.apply")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
width: 100vw;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.cursor-help {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.tail-pos-switch .svg-icon {
|
||||
background-image: url("../assets/tail-pos.svg");
|
||||
}
|
||||
.tail-pos-switch label[for="tail-bottom"] .svg-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tail-pos-switch label[for="tail-bottom"] .svg-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tail-pos-switch label[for="tail-left"] .svg-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.tail-pos-switch label[for="tail-top"] .svg-icon {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
.print-dir-switch .svg-icon {
|
||||
background-image: url("../assets/print-dir.svg");
|
||||
}
|
||||
.print-dir-switch label[for="print-dir-top"] .svg-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.label-shape-switch label[for="label-shape-rect"] .svg-icon {
|
||||
background-image: url("../assets/shape-rect.svg");
|
||||
}
|
||||
.label-shape-switch label[for="label-shape-rounded_rect"] .svg-icon {
|
||||
background-image: url("../assets/shape-rrect.svg");
|
||||
}
|
||||
.label-shape-switch label[for="label-shape-circle"] .svg-icon {
|
||||
background-image: url("../assets/shape-circle.svg");
|
||||
}
|
||||
|
||||
.label-split-switch label[for="label-split-none"] .svg-icon {
|
||||
background-image: url("../assets/shape-rrect.svg");
|
||||
}
|
||||
.label-split-switch label[for="label-split-vertical"] .svg-icon {
|
||||
background-image: url("../assets/split-vertical.svg");
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.label-split-switch label[for="label-split-horizontal"] .svg-icon {
|
||||
background-image: url("../assets/split-vertical.svg");
|
||||
}
|
||||
|
||||
.mirror-switch label[for="mirror-none"] .svg-icon {
|
||||
background-image: url("../assets/mirror-none.svg");
|
||||
}
|
||||
.mirror-switch label[for="mirror-copy"] .svg-icon {
|
||||
background-image: url("../assets/mirror-copy.svg");
|
||||
}
|
||||
.mirror-switch label[for="mirror-flip"] .svg-icon {
|
||||
background-image: url("../assets/mirror-flip.svg");
|
||||
}
|
||||
</style>
|
||||
65
web/src/components/designer-controls/ObjectPicker.svelte
Normal file
65
web/src/components/designer-controls/ObjectPicker.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { type LabelProps, type OjectType } from "$/types";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import ZplImportButton from "$/components/designer-controls/ZplImportButton.svelte";
|
||||
|
||||
interface Props {
|
||||
onSubmit: (i: OjectType) => void;
|
||||
labelProps: LabelProps;
|
||||
zplImageReady: (img: Blob) => void;
|
||||
}
|
||||
|
||||
let { onSubmit, labelProps, zplImageReady }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<MdIcon icon="format_shapes" />
|
||||
<MdIcon icon="add" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<h6 class="dropdown-header">{$tr("editor.objectpicker.title")}</h6>
|
||||
<div class="p-3">
|
||||
<button class="btn me-1" onclick={() => onSubmit("text")}>
|
||||
<MdIcon icon="title" />
|
||||
{$tr("editor.objectpicker.text")}
|
||||
</button>
|
||||
<button class="btn me-1" onclick={() => onSubmit("line")}>
|
||||
<MdIcon icon="remove" />
|
||||
{$tr("editor.objectpicker.line")}
|
||||
</button>
|
||||
<button class="btn me-1" onclick={() => onSubmit("rectangle")}>
|
||||
<MdIcon icon="crop_square" />
|
||||
{$tr("editor.objectpicker.rectangle")}
|
||||
</button>
|
||||
<button class="btn me-1" onclick={() => onSubmit("circle")}>
|
||||
<MdIcon icon="radio_button_unchecked" />
|
||||
{$tr("editor.objectpicker.circle")}
|
||||
</button>
|
||||
|
||||
<button class="btn me-1" onclick={() => onSubmit("image")}>
|
||||
<MdIcon icon="image" />
|
||||
{$tr("editor.objectpicker.image")}
|
||||
</button>
|
||||
<button class="btn me-1" onclick={() => onSubmit("qrcode")}>
|
||||
<MdIcon icon="qr_code_2" />
|
||||
{$tr("editor.objectpicker.qrcode")}
|
||||
</button>
|
||||
<button class="btn me-1" onclick={() => onSubmit("barcode")}>
|
||||
<MdIcon icon="view_week" />
|
||||
{$tr("editor.objectpicker.barcode")}
|
||||
</button>
|
||||
|
||||
<ZplImportButton {labelProps} onImageReady={zplImageReady} text={$tr("editor.import.zpl")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
width: 100vw;
|
||||
max-width: 450px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import * as fabric from "fabric";
|
||||
import { onDestroy } from "svelte";
|
||||
import QRCode from "$/fabric-object/qrcode";
|
||||
import Barcode from "$/fabric-object/barcode";
|
||||
|
||||
interface Props {
|
||||
selectedObject: fabric.FabricObject;
|
||||
}
|
||||
|
||||
let { selectedObject }: Props = $props();
|
||||
let prevObject: fabric.FabricObject | undefined;
|
||||
|
||||
let x = $state<number>();
|
||||
let y = $state<number>();
|
||||
let width = $state<number>();
|
||||
let height = $state<number>();
|
||||
|
||||
const objectDimensionsChanged = () => {
|
||||
const pos = selectedObject.getPointByOrigin("left", "top");
|
||||
x = pos.x;
|
||||
y = pos.y;
|
||||
width = selectedObject.width;
|
||||
height = selectedObject.height;
|
||||
};
|
||||
|
||||
const objectChanged = (newObject: fabric.FabricObject) => {
|
||||
if (prevObject !== undefined) {
|
||||
prevObject.off("modified", objectDimensionsChanged);
|
||||
}
|
||||
|
||||
newObject.on("modified", objectDimensionsChanged);
|
||||
objectDimensionsChanged();
|
||||
|
||||
prevObject = newObject;
|
||||
};
|
||||
|
||||
const updateObject = () => {
|
||||
const newPos = new fabric.Point(Math.round(x!), Math.round(y!));
|
||||
|
||||
selectedObject.setPositionByOrigin(newPos, "left", "top");
|
||||
|
||||
selectedObject.set({
|
||||
width: Math.round(Math.max(width!, 1)),
|
||||
height: Math.round(Math.max(height!, 1)),
|
||||
});
|
||||
|
||||
selectedObject.setCoords();
|
||||
selectedObject.canvas?.requestRenderAll();
|
||||
};
|
||||
|
||||
onDestroy(() => selectedObject.off("modified", objectDimensionsChanged));
|
||||
|
||||
$effect(() => {
|
||||
objectChanged(selectedObject);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
title={$tr("params.generic.position")}>
|
||||
<MdIcon icon="control_camera" />
|
||||
</button>
|
||||
<div class="dropdown-menu arrangement p-2">
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<span class="input-group-text">x</span>
|
||||
<input class="form-control" type="number" bind:value={x} onchange={updateObject} />
|
||||
</div>
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<span class="input-group-text">y</span>
|
||||
<input class="form-control" type="number" bind:value={y} onchange={updateObject} />
|
||||
</div>
|
||||
{#if !(selectedObject instanceof fabric.FabricText || selectedObject instanceof fabric.FabricImage || selectedObject instanceof QRCode || selectedObject instanceof Barcode)}
|
||||
<div class="input-group flex-nowrap input-group-sm mb-2">
|
||||
<input class="form-control" type="number" min="1" bind:value={width} onchange={updateObject} />
|
||||
<span class="input-group-text">x</span>
|
||||
<input class="form-control" type="number" min="1" bind:value={height} onchange={updateObject} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { QRCode } from "$/fabric-object/qrcode";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
selectedQRCode: QRCode;
|
||||
editRevision: number;
|
||||
valueUpdated: () => void;
|
||||
}
|
||||
|
||||
let { selectedQRCode, editRevision, valueUpdated }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="hidden" value={editRevision}>
|
||||
|
||||
<div class="input-group input-group-sm flex-nowrap">
|
||||
<span class="input-group-text" title={$tr("params.qrcode.ecl")}>
|
||||
<MdIcon icon="auto_fix_high" />
|
||||
</span>
|
||||
<select
|
||||
class="form-select"
|
||||
value={selectedQRCode.ecl}
|
||||
onchange={(e) => {
|
||||
selectedQRCode?.set("ecl", e.currentTarget.value);
|
||||
valueUpdated();
|
||||
}}>
|
||||
<option value="L">Level L</option>
|
||||
<option value="M">Level M</option>
|
||||
<option value="Q">Level Q</option>
|
||||
<option value="H">Level H</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm flex-nowrap">
|
||||
<span class="input-group-text" title={$tr("params.qrcode.mode")}>
|
||||
<MdIcon icon="abc" />
|
||||
</span>
|
||||
<select
|
||||
class="form-select"
|
||||
value={selectedQRCode.mode}
|
||||
onchange={(e) => {
|
||||
selectedQRCode?.set("mode", e.currentTarget.value);
|
||||
valueUpdated();
|
||||
}}>
|
||||
<option value="Byte">Byte</option>
|
||||
<option value="Numeric">Numeric</option>
|
||||
<option value="Alphanumeric">Alphanumeric</option>
|
||||
<option value="Kanji">Kanji</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm flex-nowrap">
|
||||
<span class="input-group-text" title={$tr("params.qrcode.version")}>
|
||||
<MdIcon icon="123" />
|
||||
</span>
|
||||
<select
|
||||
class="form-select"
|
||||
value={selectedQRCode.qrVersion}
|
||||
onchange={(e) => {
|
||||
selectedQRCode?.set("qrVersion", parseInt(e.currentTarget.value));
|
||||
valueUpdated();
|
||||
}}>
|
||||
<option value={0}>Auto</option>
|
||||
{#each { length: 40 }, i (i)}
|
||||
<option value={i + 1}>{i + 1}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
class="qrcode-content form-control"
|
||||
value={selectedQRCode.text}
|
||||
oninput={(e) => {
|
||||
selectedQRCode?.set("text", e.currentTarget.value);
|
||||
valueUpdated();
|
||||
}}></textarea>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.qrcode-content {
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
146
web/src/components/designer-controls/SavedLabelsBrowser.svelte
Normal file
146
web/src/components/designer-controls/SavedLabelsBrowser.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import type { ExportedLabelTemplate, LabelProps } from "$/types";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
onItemClicked: (index: number) => void;
|
||||
onItemDelete: (index: number) => void;
|
||||
onItemExport: (index: number) => void;
|
||||
labels: ExportedLabelTemplate[];
|
||||
selectedIndex?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { onItemClicked, onItemDelete, onItemExport, labels, selectedIndex = -1, class: className }: Props = $props();
|
||||
|
||||
let deleteIndex = $state<number>(-1);
|
||||
|
||||
const scaleDimensions = (preset: LabelProps): { width: number; height: number } => {
|
||||
const scaleFactor = Math.min(100 / preset.size.width, 100 / preset.size.height);
|
||||
return {
|
||||
width: Math.round(preset.size.width * scaleFactor),
|
||||
height: Math.round(preset.size.height * scaleFactor),
|
||||
};
|
||||
};
|
||||
|
||||
const deleteConfirmed = (e: MouseEvent, idx: number) => {
|
||||
e.stopPropagation();
|
||||
deleteIndex = -1;
|
||||
onItemDelete(idx);
|
||||
};
|
||||
|
||||
const deleteRejected = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
deleteIndex = -1;
|
||||
};
|
||||
|
||||
const deleteRequested = (e: MouseEvent, idx: number) => {
|
||||
e.stopPropagation();
|
||||
deleteIndex = idx;
|
||||
};
|
||||
|
||||
const exportRequested = (e: MouseEvent, idx: number) => {
|
||||
e.stopPropagation();
|
||||
onItemExport(idx);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="labels-browser overflow-y-auto border d-flex p-2 gap-1 flex-wrap {className}">
|
||||
{#each labels as item, idx (item.id ?? item.timestamp)}
|
||||
<div
|
||||
tabindex="0"
|
||||
class="btn p-0 card-wrapper d-flex justify-content-center align-items-center {selectedIndex === idx
|
||||
? 'border-primary'
|
||||
: ''}"
|
||||
onkeydown={() => onItemClicked(idx)}
|
||||
onclick={() => onItemClicked(idx)}
|
||||
role="button">
|
||||
<div
|
||||
class="card print-start-{item.label.printDirection} d-flex justify-content-center align-items-center"
|
||||
style="width: {scaleDimensions(item.label).width}%; height: {scaleDimensions(item.label).height}%;">
|
||||
<div class="buttons d-flex">
|
||||
<button
|
||||
class="btn text-primary-emphasis"
|
||||
onclick={(e) => exportRequested(e, idx)}
|
||||
title={$tr("params.saved_labels.save.json")}>
|
||||
<MdIcon icon="download" />
|
||||
</button>
|
||||
|
||||
{#if deleteIndex === idx}
|
||||
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteConfirmed(e, idx)}>
|
||||
<MdIcon icon="delete" />
|
||||
</button>
|
||||
<button class="remove btn text-success" onclick={(e) => deleteRejected(e)}>
|
||||
<MdIcon icon="close" />
|
||||
</button>
|
||||
{:else}
|
||||
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteRequested(e, idx)}>
|
||||
<MdIcon icon="delete" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if item.thumbnailBase64}
|
||||
<img class="thumbnail" src={item.thumbnailBase64} alt="thumbnail" />
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<span class="label p-1">
|
||||
{item.title}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.labels-browser {
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card > .buttons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card > .buttons > button {
|
||||
padding: 0;
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.card > .label {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
color: black;
|
||||
border-radius: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card.print-start-left {
|
||||
border-left: 2px solid #ff4646;
|
||||
}
|
||||
.card.print-start-top {
|
||||
border-top: 2px solid #ff4646;
|
||||
}
|
||||
|
||||
.card .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
304
web/src/components/designer-controls/SavedLabelsMenu.svelte
Normal file
304
web/src/components/designer-controls/SavedLabelsMenu.svelte
Normal file
@@ -0,0 +1,304 @@
|
||||
<script lang="ts">
|
||||
import { tr } from "$/utils/i18n";
|
||||
import { onMount } from "svelte";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import SavedLabelsBrowser from "$/components/designer-controls/SavedLabelsBrowser.svelte";
|
||||
import { ExportedLabelTemplateSchema, type ExportedLabelTemplate } from "$/types";
|
||||
import { LocalStoragePersistence } from "$/utils/persistence";
|
||||
import { Toasts } from "$/utils/toasts";
|
||||
import Dropdown from "bootstrap/js/dist/dropdown";
|
||||
import { FileUtils } from "$/utils/file_utils";
|
||||
import * as fabric from "fabric";
|
||||
interface Props {
|
||||
onRequestLabelTemplate: () => ExportedLabelTemplate;
|
||||
onLoadRequested: (label: ExportedLabelTemplate) => void;
|
||||
canvas: fabric.Canvas;
|
||||
csvEnabled: boolean;
|
||||
}
|
||||
|
||||
let { onRequestLabelTemplate, onLoadRequested, canvas, csvEnabled }: Props = $props();
|
||||
|
||||
let dropdownRef: HTMLDivElement;
|
||||
let savedLabels = $state<ExportedLabelTemplate[]>([]);
|
||||
let selectedIndex = $state<number>(-1);
|
||||
let title = $state<string>("");
|
||||
let usedSpace = $state<number>(0);
|
||||
let customDefaultTemplate = $state<boolean>(LocalStoragePersistence.hasCustomDefaultTemplate());
|
||||
const calcUsedSpace = () => {
|
||||
usedSpace = LocalStoragePersistence.usedSpace();
|
||||
};
|
||||
|
||||
const onLabelSelected = (index: number) => {
|
||||
selectedIndex = index;
|
||||
title = savedLabels[index]?.title ?? "";
|
||||
};
|
||||
|
||||
const onLabelExport = (idx: number) => {
|
||||
try {
|
||||
FileUtils.saveLabelAsJson(savedLabels[idx]);
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Canvas save error:");
|
||||
}
|
||||
};
|
||||
|
||||
const onLabelDelete = (idx: number) => {
|
||||
selectedIndex = -1;
|
||||
const result = [...savedLabels];
|
||||
result.splice(idx, 1);
|
||||
LocalStoragePersistence.saveLabels(result);
|
||||
|
||||
savedLabels = result;
|
||||
title = "";
|
||||
calcUsedSpace();
|
||||
};
|
||||
|
||||
const saveLabels = (labels: ExportedLabelTemplate[]) => {
|
||||
const { zodErrors, otherErrors } = LocalStoragePersistence.saveLabels(labels);
|
||||
zodErrors.forEach((e) => Toasts.zodErrors(e, "Label save error"));
|
||||
otherErrors.forEach((e) => Toasts.error(e));
|
||||
|
||||
if (zodErrors.length === 0 && otherErrors.length === 0) {
|
||||
savedLabels = labels;
|
||||
}
|
||||
|
||||
calcUsedSpace();
|
||||
};
|
||||
|
||||
const onSaveReplaceClicked = () => {
|
||||
if (selectedIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm($tr("editor.warning.save"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = onRequestLabelTemplate();
|
||||
label.title = title;
|
||||
|
||||
const result = [...savedLabels];
|
||||
result[selectedIndex] = label;
|
||||
|
||||
saveLabels(result);
|
||||
};
|
||||
|
||||
const onMakeDefaultClicked = () => {
|
||||
const label = onRequestLabelTemplate();
|
||||
label.title = title;
|
||||
label.thumbnailBase64 = undefined;
|
||||
LocalStoragePersistence.saveDefaultTemplate(label);
|
||||
customDefaultTemplate = true;
|
||||
calcUsedSpace();
|
||||
};
|
||||
|
||||
const onRemoveDefaultClicked = () => {
|
||||
LocalStoragePersistence.saveDefaultTemplate(undefined);
|
||||
customDefaultTemplate = false;
|
||||
calcUsedSpace();
|
||||
};
|
||||
|
||||
const onSaveClicked = () => {
|
||||
const label = onRequestLabelTemplate();
|
||||
label.title = title;
|
||||
const result = [...savedLabels, label];
|
||||
saveLabels(result);
|
||||
};
|
||||
|
||||
const onLoadClicked = () => {
|
||||
if (selectedIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = savedLabels[selectedIndex];
|
||||
|
||||
let message = $tr("editor.warning.load");
|
||||
|
||||
if (label.csv) {
|
||||
message += "\n" + $tr("editor.warning.load.csv");
|
||||
}
|
||||
|
||||
if (!confirm(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLoadRequested(label);
|
||||
new Dropdown(dropdownRef).hide();
|
||||
};
|
||||
|
||||
const onImportClicked = async () => {
|
||||
const contents = await FileUtils.pickAndReadSingleTextFile("json");
|
||||
const rawData = JSON.parse(contents);
|
||||
|
||||
|
||||
try {
|
||||
const label = ExportedLabelTemplateSchema.parse(rawData);
|
||||
|
||||
let message = $tr("editor.warning.load");
|
||||
|
||||
if (label.csv) {
|
||||
message += "\n" + $tr("editor.warning.load.csv");
|
||||
}
|
||||
|
||||
if (!confirm(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLoadRequested(label);
|
||||
|
||||
if (label.title) {
|
||||
title = label.title;
|
||||
}
|
||||
|
||||
new Dropdown(dropdownRef).hide();
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Canvas load error:");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportClicked = () => {
|
||||
try {
|
||||
const label = onRequestLabelTemplate();
|
||||
if (title) {
|
||||
label.title = title.replaceAll(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
FileUtils.saveLabelAsJson(label);
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Canvas save error:");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportPngClicked = () => {
|
||||
try {
|
||||
FileUtils.saveCanvasAsPng(canvas);
|
||||
} catch (e) {
|
||||
Toasts.zodErrors(e, "Canvas save error:");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportUrlClicked = async () => {
|
||||
try {
|
||||
const label = onRequestLabelTemplate();
|
||||
const url = await FileUtils.makeLabelUrl(label);
|
||||
|
||||
if (url.length > 2000 && !confirm($tr("params.saved_labels.save.url.warn"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(url);
|
||||
Toasts.message($tr("params.saved_labels.save.url.copied"));
|
||||
} catch (e) {
|
||||
Toasts.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
savedLabels = LocalStoragePersistence.loadLabels();
|
||||
calcUsedSpace();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<MdIcon icon="sd_storage" />
|
||||
</button>
|
||||
<div class="saved-labels dropdown-menu" bind:this={dropdownRef}>
|
||||
<h6 class="dropdown-header text-wrap">
|
||||
{$tr("params.saved_labels.menu_title")} - {usedSpace}
|
||||
{$tr("params.saved_labels.kb_used")}
|
||||
|
||||
{#if csvEnabled}
|
||||
<div class="pt-3 text-warning">
|
||||
{$tr("params.saved_labels.save.withcsv")}
|
||||
</div>
|
||||
{/if}
|
||||
</h6>
|
||||
|
||||
|
||||
<div class="px-3">
|
||||
<div class="p-1">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick={onImportClicked}>
|
||||
<MdIcon icon="data_object" />
|
||||
{$tr("params.saved_labels.load.json")}
|
||||
</button>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" onclick={onExportClicked}>
|
||||
<MdIcon icon="data_object" />
|
||||
{$tr("params.saved_labels.save.json")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="dropdown"
|
||||
class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
|
||||
data-bs-toggle="dropdown">
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<button class="dropdown-item" onclick={onExportPngClicked}>PNG</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" onclick={onExportUrlClicked}
|
||||
>{$tr("params.saved_labels.save.url")}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SavedLabelsBrowser
|
||||
class="mb-1"
|
||||
{selectedIndex}
|
||||
labels={savedLabels}
|
||||
onItemClicked={onLabelSelected}
|
||||
onItemDelete={onLabelDelete}
|
||||
onItemExport={onLabelExport} />
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-3">
|
||||
<span class="input-group-text">{$tr("params.saved_labels.label_title")}</span>
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
placeholder={$tr("params.saved_labels.label_title.placeholder")}
|
||||
bind:value={title} />
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-1 flex-wrap justify-content-end">
|
||||
<div class="btn-group btn-group-sm make-default">
|
||||
<button class="btn text-secondary" onclick={onMakeDefaultClicked}>
|
||||
{$tr("params.saved_labels.make_default")}
|
||||
</button>
|
||||
{#if customDefaultTemplate}
|
||||
<button class="btn text-secondary" onclick={onRemoveDefaultClicked}>
|
||||
<MdIcon icon="close" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-secondary" onclick={onSaveClicked}>
|
||||
<MdIcon icon="save" />
|
||||
{$tr("params.saved_labels.save.browser")}
|
||||
</button>
|
||||
|
||||
{#if selectedIndex !== -1}
|
||||
<button class="btn btn-sm btn-secondary" onclick={onSaveReplaceClicked}>
|
||||
<MdIcon icon="edit_note" />
|
||||
{$tr("params.saved_labels.save.browser.replace")}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-primary" onclick={onLoadClicked}>
|
||||
<MdIcon icon="folder" />
|
||||
{$tr("params.saved_labels.load.browser")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.saved-labels.dropdown-menu {
|
||||
width: 100vw;
|
||||
max-width: 450px;
|
||||
}
|
||||
.make-default {
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
287
web/src/components/designer-controls/TextParamsControls.svelte
Normal file
287
web/src/components/designer-controls/TextParamsControls.svelte
Normal file
@@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
import * as fabric from "fabric";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import FontFamilyPicker from "$/components/designer-controls/FontFamilyPicker.svelte";
|
||||
import { TextboxExt } from "$/fabric-object/textbox-ext";
|
||||
|
||||
interface Props {
|
||||
selectedText: fabric.IText;
|
||||
editRevision: number;
|
||||
valueUpdated: () => void;
|
||||
}
|
||||
|
||||
let { selectedText, editRevision, valueUpdated }: Props = $props();
|
||||
|
||||
let sizeMin: number = 1;
|
||||
let sizeMax: number = 999;
|
||||
|
||||
const setXAlign = (align: fabric.TOriginX) => {
|
||||
selectedText.set({ textAlign: align });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const setYAlign = (align: fabric.TOriginY) => {
|
||||
// change object origin, but keep position
|
||||
const pos = selectedText.getPointByOrigin("left", "top");
|
||||
selectedText.set({ originY: align });
|
||||
selectedText.setPositionByOrigin(pos, "left", "top");
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const toggleBold = () => {
|
||||
if (selectedText.fontWeight === "bold") {
|
||||
selectedText.fontWeight = "normal";
|
||||
} else {
|
||||
selectedText.fontWeight = "bold";
|
||||
}
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const toggleItalic = () => {
|
||||
if (selectedText.fontStyle === "italic") {
|
||||
selectedText.fontStyle = "normal";
|
||||
} else {
|
||||
selectedText.fontStyle = "italic";
|
||||
}
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const toggleFontAutoSize = () => {
|
||||
if (selectedText instanceof TextboxExt) {
|
||||
selectedText.set({ fontAutoSize: !selectedText.fontAutoSize });
|
||||
}
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const updateFontFamily = (v: string) => {
|
||||
selectedText.set({ fontFamily: v });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const fontSizeUp = () => {
|
||||
let s = selectedText.fontSize;
|
||||
selectedText.set({ fontSize: Math.min(s > 40 ? Math.round(s * 1.1) : s + 2, sizeMax) });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const fontSizeDown = () => {
|
||||
let s = selectedText.fontSize;
|
||||
selectedText.set({ fontSize: Math.max(s > 40 ? Math.round(s * 0.9) : s - 2, sizeMin) });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const lineHeightChange = (v: number) => {
|
||||
v = isNaN(v) ? 1 : v;
|
||||
selectedText.set({ lineHeight: v });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const fontSizeChange = (v: number) => {
|
||||
v = isNaN(v) ? 1 : Math.min(Math.max(v, sizeMin), sizeMax);
|
||||
selectedText.set({ fontSize: v });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const fillChanged = (value: string) => {
|
||||
selectedText.set({ fill: value });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const splitChanged = (value: string) => {
|
||||
if (selectedText instanceof fabric.Textbox) {
|
||||
selectedText.set({ splitByGrapheme: value === "grapheme" });
|
||||
valueUpdated();
|
||||
}
|
||||
};
|
||||
|
||||
const backgroundColorChanged = (value: string) => {
|
||||
selectedText.set({ backgroundColor: value });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const editInPopup = () => {
|
||||
const text = prompt($tr("params.text.edit.title"), selectedText.text);
|
||||
if (text !== null) {
|
||||
selectedText.set({ text });
|
||||
selectedText.isEditing = false;
|
||||
valueUpdated();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Fix component not updating when selectedText changes. I didn't find a better way to do this. -->
|
||||
<input type="hidden" value={editRevision}>
|
||||
|
||||
<button
|
||||
title={$tr("params.text.align.left")}
|
||||
class="btn btn-sm {selectedText.textAlign === 'left' ? 'btn-secondary' : ''}"
|
||||
onclick={() => setXAlign("left")}><MdIcon icon="format_align_left" /></button>
|
||||
<button
|
||||
title={$tr("params.text.align.center")}
|
||||
class="btn btn-sm {selectedText.textAlign === 'center' ? 'btn-secondary' : ''}"
|
||||
onclick={() => setXAlign("center")}><MdIcon icon="format_align_center" /></button>
|
||||
<button
|
||||
title={$tr("params.text.align.right")}
|
||||
class="btn btn-sm {selectedText.textAlign === 'right' ? 'btn-secondary' : ''}"
|
||||
onclick={() => setXAlign("right")}><MdIcon icon="format_align_right" /></button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" title={$tr("params.text.vorigin")}>
|
||||
{#if selectedText.originY === "top"}
|
||||
<MdIcon icon="vertical_align_top" />
|
||||
{:else if selectedText.originY === "center"}
|
||||
<MdIcon icon="vertical_align_center" />
|
||||
{:else if selectedText.originY === "bottom"}
|
||||
<MdIcon icon="vertical_align_bottom" />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="dropdown-menu p-2">
|
||||
<button
|
||||
class="btn btn-sm {selectedText.originY === 'top' ? 'btn-secondary' : ''}"
|
||||
onclick={() => setYAlign("top")}
|
||||
title={$tr("params.text.vorigin.top")}>
|
||||
<MdIcon icon="vertical_align_top" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm {selectedText.originY === 'center' ? 'btn-secondary' : ''}"
|
||||
onclick={() => setYAlign("center")}
|
||||
title={$tr("params.text.vorigin.center")}>
|
||||
<MdIcon icon="vertical_align_center" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm {selectedText.originY === 'bottom' ? 'btn-secondary' : ''}"
|
||||
onclick={() => setYAlign("bottom")}
|
||||
title={$tr("params.text.vorigin.bottom")}>
|
||||
<MdIcon icon="vertical_align_bottom" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-sm {selectedText.fontWeight === 'bold' ? 'btn-secondary' : ''}"
|
||||
title={$tr("params.text.bold")}
|
||||
onclick={toggleBold}>
|
||||
<MdIcon icon="format_bold" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm {selectedText.fontStyle === 'italic' ? 'btn-secondary' : ''}"
|
||||
title={$tr("params.text.italic")}
|
||||
onclick={toggleItalic}>
|
||||
<MdIcon icon="format_italic" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" title={$tr("params.color")}>
|
||||
<MdIcon icon="format_color_fill" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu arrangement p-2">
|
||||
<div class="input-group input-group-sm flex-nowrap color pb-2">
|
||||
<span class="input-group-text">
|
||||
<MdIcon icon="format_color_text" />
|
||||
</span>
|
||||
<select class="form-select" value={selectedText.fill} onchange={(e) => fillChanged(e.currentTarget.value)}>
|
||||
<option value="white">{$tr("params.color.white")}</option>
|
||||
<option value="black">{$tr("params.color.black")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group input-group-sm flex-nowrap color pb-2">
|
||||
<span class="input-group-text">
|
||||
<MdIcon icon="format_color_fill" />
|
||||
</span>
|
||||
<select
|
||||
class="form-select"
|
||||
value={selectedText.backgroundColor || "transparent"}
|
||||
onchange={(e) => backgroundColorChanged(e.currentTarget.value)}>
|
||||
<option value="white">{$tr("params.color.white")}</option>
|
||||
<option value="black">{$tr("params.color.black")}</option>
|
||||
<option value="transparent">{$tr("params.color.transparent")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedText instanceof fabric.Textbox}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" title={$tr("params.params.text.split")}>
|
||||
<MdIcon icon="wrap_text" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu arrangement p-2">
|
||||
<div class="input-group input-group-sm flex-nowrap split pb-2">
|
||||
<select class="form-select" value={selectedText.splitByGrapheme ? "grapheme" : "space"} onchange={(e) => splitChanged(e.currentTarget.value)}>
|
||||
<option value="space">{$tr("params.params.text.split.spaces")}</option>
|
||||
<option value="grapheme">{$tr("params.params.text.split.grapheme")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedText instanceof TextboxExt}
|
||||
<!-- fixme: Custom property not auto-rendered for some reason -->
|
||||
<button
|
||||
class="btn btn-sm {selectedText.fontAutoSize ? 'btn-secondary' : ''}"
|
||||
title={$tr("params.text.autosize")}
|
||||
data-ver={editRevision}
|
||||
onclick={toggleFontAutoSize}>
|
||||
<MdIcon icon="expand" class="r-90" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm font-size">
|
||||
<span class="input-group-text" title={$tr("params.text.font_size")}><MdIcon icon="format_size" /></span>
|
||||
<input
|
||||
type="number"
|
||||
min={sizeMin}
|
||||
max={sizeMax}
|
||||
step="2"
|
||||
class="form-control"
|
||||
value={selectedText.fontSize}
|
||||
oninput={(e) => fontSizeChange(e.currentTarget.valueAsNumber)} />
|
||||
<button class="btn btn-secondary" title={$tr("params.text.font_size.up")} onclick={fontSizeUp}>
|
||||
<MdIcon icon="text_increase" />
|
||||
</button>
|
||||
<button class="btn btn-secondary" title={$tr("params.text.font_size.down")} onclick={fontSizeDown}>
|
||||
<MdIcon icon="text_decrease" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm">
|
||||
<span class="input-group-text" title={$tr("params.text.line_height")}>
|
||||
<MdIcon icon="density_medium" />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
max="10"
|
||||
class="form-control"
|
||||
value={selectedText.lineHeight}
|
||||
oninput={(e) => lineHeightChange(e.currentTarget.valueAsNumber)} />
|
||||
</div>
|
||||
|
||||
<FontFamilyPicker {editRevision} value={selectedText.fontFamily} valueUpdated={updateFontFamily} />
|
||||
|
||||
<button class="btn btn-sm btn-secondary" onclick={editInPopup} title={$tr("params.text.edit")}>
|
||||
<MdIcon icon="edit" />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
width: 7em;
|
||||
}
|
||||
.font-size {
|
||||
width: 12em;
|
||||
}
|
||||
.input-group.color {
|
||||
width: 12em;
|
||||
}
|
||||
.input-group.split {
|
||||
width: 14em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import * as fabric from "fabric";
|
||||
import { tr } from "$/utils/i18n";
|
||||
import QRCode from "$/fabric-object/qrcode";
|
||||
import Barcode from "$/fabric-object/barcode";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
selectedObject: fabric.FabricObject;
|
||||
valueUpdated: () => void;
|
||||
}
|
||||
|
||||
let { selectedObject, valueUpdated }: Props = $props();
|
||||
|
||||
const insertDateTime = (format?: string) => {
|
||||
let value = "{dt}";
|
||||
if (format) {
|
||||
value = `{dt|${format}}`;
|
||||
}
|
||||
|
||||
if (selectedObject instanceof fabric.IText) {
|
||||
selectedObject.exitEditing();
|
||||
selectedObject.set({ text: `${selectedObject.text}${value}` });
|
||||
} else if (selectedObject instanceof QRCode) {
|
||||
selectedObject.set({ text: `${selectedObject.text}${value}` });
|
||||
} else if (selectedObject instanceof Barcode) {
|
||||
selectedObject.set({ text: `${selectedObject.text}${value}` });
|
||||
}
|
||||
|
||||
valueUpdated();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group" title={$tr("params.variables.insert")}>
|
||||
<button class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<MdIcon icon="data_object" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu px-2">
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => insertDateTime()}>
|
||||
<MdIcon icon="calendar_today" />
|
||||
{$tr("params.variables.insert.datetime")}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => insertDateTime("YYYY-MM-DD")}>
|
||||
<MdIcon icon="calendar_today" />
|
||||
{$tr("params.variables.insert.date")}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => insertDateTime("HH:mm:ss")}>
|
||||
<MdIcon icon="schedule" />
|
||||
{$tr("params.variables.insert.time")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { tr } from "$/utils/i18n";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
import * as fabric from "fabric";
|
||||
|
||||
interface Props {
|
||||
selectedObject: fabric.FabricObject;
|
||||
editRevision: number;
|
||||
valueUpdated: () => void;
|
||||
}
|
||||
|
||||
let { selectedObject, editRevision, valueUpdated }: Props = $props();
|
||||
|
||||
const roundRadiusChanged = (value: number) => {
|
||||
const rect = selectedObject as fabric.Rect;
|
||||
rect.set({
|
||||
rx: value,
|
||||
ry: value,
|
||||
});
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const strokeWidthChanged = (value: number) => {
|
||||
selectedObject.set({ strokeWidth: value });
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
const fillChanged = (value: string) => {
|
||||
selectedObject.set({ fill: value });
|
||||
valueUpdated();
|
||||
};
|
||||
</script>
|
||||
|
||||
<input type="hidden" value={editRevision}>
|
||||
|
||||
{#if selectedObject instanceof fabric.Rect}
|
||||
<div class="input-group flex-nowrap input-group-sm">
|
||||
<span class="input-group-text" title={$tr("params.vector.round_radius")}>
|
||||
<MdIcon icon="rounded_corner" />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={Math.min(selectedObject.width, selectedObject.height) / 2}
|
||||
class="form-control"
|
||||
value={selectedObject.rx}
|
||||
oninput={(e) => roundRadiusChanged(e.currentTarget.valueAsNumber)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedObject instanceof fabric.Rect || selectedObject instanceof fabric.Circle || selectedObject instanceof fabric.Line}
|
||||
<div class="input-group flex-nowrap input-group-sm">
|
||||
<span class="input-group-text" title={$tr("params.vector.stroke_width")}>
|
||||
<MdIcon icon="line_weight" />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
class="form-control"
|
||||
value={selectedObject.strokeWidth}
|
||||
oninput={(e) => strokeWidthChanged(e.currentTarget.valueAsNumber)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedObject instanceof fabric.Rect || selectedObject instanceof fabric.Circle}
|
||||
<div class="input-group input-group-sm flex-nowrap fill">
|
||||
<span class="input-group-text" title={$tr("params.vector.fill")}>
|
||||
<MdIcon icon="format_color_fill" />
|
||||
</span>
|
||||
<select
|
||||
class="form-select"
|
||||
value={selectedObject.fill}
|
||||
onchange={(e) => fillChanged(e.currentTarget.value)}>
|
||||
<option value="transparent">{$tr("params.color.transparent")}</option>
|
||||
<option value="white">{$tr("params.color.white")}</option>
|
||||
<option value="black">{$tr("params.color.black")}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
width: 7em;
|
||||
}
|
||||
.input-group.fill {
|
||||
width: 12em;
|
||||
}
|
||||
</style>
|
||||
60
web/src/components/designer-controls/ZplImportButton.svelte
Normal file
60
web/src/components/designer-controls/ZplImportButton.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { LabelProps } from "$/types";
|
||||
import { FileUtils } from "$/utils/file_utils";
|
||||
import MdIcon from "$/components/basic/MdIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
labelProps: LabelProps;
|
||||
onImageReady: (img: Blob) => void;
|
||||
}
|
||||
|
||||
let { text, labelProps, onImageReady }: Props = $props();
|
||||
let importState = $state<"idle" | "processing" | "error">("idle");
|
||||
|
||||
const onImportClicked = async () => {
|
||||
const mmToInchCoeff = 25.4;
|
||||
const dpmm = 8; // todo: may vary, make it configurable
|
||||
const widthInches = labelProps.size.width / dpmm / mmToInchCoeff;
|
||||
const heightInches = labelProps.size.height / dpmm / mmToInchCoeff;
|
||||
|
||||
const contents = await FileUtils.pickAndReadSingleTextFile("zpl");
|
||||
|
||||
importState = "processing";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.labelary.com/v1/printers/${dpmm}dpmm/labels/${widthInches}x${heightInches}/0/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "image/png",
|
||||
"X-Quality": "bitonal",
|
||||
},
|
||||
body: contents,
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const img = await response.blob();
|
||||
onImageReady(img);
|
||||
importState = "idle";
|
||||
} else {
|
||||
importState = "error";
|
||||
}
|
||||
} catch (e) {
|
||||
importState = "error";
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button class="btn btn-sm" onclick={onImportClicked}>
|
||||
<MdIcon icon="receipt_long" />
|
||||
{text}
|
||||
{#if importState === "processing"}
|
||||
<MdIcon icon="hourglass_top" />
|
||||
{:else if importState === "error"}
|
||||
<MdIcon icon="warning" class="text-warning" />
|
||||
{/if}
|
||||
</button>
|
||||
Reference in New Issue
Block a user