Port NiimBlue label designer to Fichero D11s with local BLE protocol library

This commit is contained in:
Hamza
2026-03-01 22:47:16 +01:00
parent b1ff403594
commit 774b02bb99
102 changed files with 16333 additions and 951 deletions

View File

@@ -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>

View 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>

View 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>

View 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>

View 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}

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>