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,37 @@
<script lang="ts">
import AppModal from "$/components/basic/AppModal.svelte";
import { appConfig } from "$/stores";
import { tr } from "$/utils/i18n";
import { FICHERO_CLIENT_DEFAULTS } from "$/lib/fichero";
let { show = $bindable() } = $props();
</script>
<AppModal title={$tr("debug.title")} bind:show>
<div class="mb-1">
{$tr("debug.packet_interval.help")}
</div>
<div class="input-group flex-nowrap input-group-sm mb-3">
<input
class="form-control"
type="number"
min="1"
placeholder={`${FICHERO_CLIENT_DEFAULTS.packetIntervalMs}`}
bind:value={$appConfig.packetIntervalMs} />
<span class="input-group-text">ms</span>
<button class="btn btn-outline-secondary" onclick={() => ($appConfig.packetIntervalMs = undefined)}
>{$tr("debug.reset")}</button>
</div>
<div class="mb-1">
{$tr("debug.page_delay.help")}
</div>
<div class="input-group flex-nowrap input-group-sm mb-3" role="group">
<input class="form-control" type="number" min="0" placeholder="0" bind:value={$appConfig.pageDelay} />
<span class="input-group-text">ms</span>
<button class="btn btn-outline-secondary" onclick={() => ($appConfig.pageDelay = undefined)}
>{$tr("debug.reset")}</button>
</div>
</AppModal>

View File

@@ -0,0 +1,563 @@
<script lang="ts">
import Dropdown from "bootstrap/js/dist/dropdown";
import * as fabric from "fabric";
import { onDestroy, onMount, tick } from "svelte";
import { Barcode } from "$/fabric-object/barcode";
import { QRCode } from "$/fabric-object/qrcode";
import { iconCodepoints, type MaterialIcon } from "$/styles/mdi_icons";
import { automation, connectionState, csvData, loadedFonts } from "$/stores";
import {
type ExportedLabelTemplate,
type FabricJson,
type LabelProps,
type MoveDirection,
type OjectType,
} from "$/types";
import { FileUtils } from "$/utils/file_utils";
import { tr } from "$/utils/i18n";
import { LabelDesignerObjectHelper } from "$/utils/label_designer_object_helper";
import { LocalStoragePersistence } from "$/utils/persistence";
import { Toasts } from "$/utils/toasts";
import { UndoRedo, type UndoState } from "$/utils/undo_redo";
import BarcodeParamsPanel from "$/components/designer-controls/BarcodeParamsControls.svelte";
import CsvControl from "$/components/designer-controls/CsvControl.svelte";
import GenericObjectParamsControls from "$/components/designer-controls/GenericObjectParamsControls.svelte";
import IconPicker from "$/components/designer-controls/IconPicker.svelte";
import LabelPropsEditor from "$/components/designer-controls/LabelPropsEditor.svelte";
import MdIcon from "$/components/basic/MdIcon.svelte";
import ObjectPicker from "$/components/designer-controls/ObjectPicker.svelte";
import PrintPreview from "$/components/PrintPreview.svelte";
import QrCodeParamsPanel from "$/components/designer-controls/QRCodeParamsControls.svelte";
import TextParamsControls from "$/components/designer-controls/TextParamsControls.svelte";
import VariableInsertControl from "$/components/designer-controls/VariableInsertControl.svelte";
import { DEFAULT_LABEL_PROPS, GRID_SIZE } from "$/defaults";
import { LabelDesignerUtils } from "$/utils/label_designer_utils";
import SavedLabelsMenu from "$/components/designer-controls/SavedLabelsMenu.svelte";
import { CustomCanvas } from "$/fabric-object/custom_canvas";
import VectorParamsControls from "$/components/designer-controls/VectorParamsControls.svelte";
import { CanvasUtils } from "$/utils/canvas_utils";
let htmlCanvas: HTMLCanvasElement;
let fabricCanvas = $state<CustomCanvas>();
let labelProps = $state<LabelProps>(DEFAULT_LABEL_PROPS);
let previewOpened = $state<boolean>(false);
let selectedObject = $state<fabric.FabricObject | undefined>(undefined);
let selectedCount = $state<number>(0);
let editRevision = $state<number>(0);
let printNow = $state<boolean>(false);
let csvEnabled = $state<boolean>(false);
let windowWidth = $state<number>(0);
let undoState = $state<UndoState>({ undoDisabled: false, redoDisabled: false });
const undo = new UndoRedo();
const discardSelection = () => {
fabricCanvas!.discardActiveObject();
fabricCanvas!.requestRenderAll();
selectedObject = undefined;
selectedCount = 0;
editRevision = 0;
};
const loadLabelData = async (data: ExportedLabelTemplate) => {
undo.paused = true;
onUpdateLabelProps(data.label);
if (data.csv) {
$csvData = data.csv;
csvEnabled = true;
}
await FileUtils.loadCanvasState(fabricCanvas!, data.canvas);
undo.paused = false;
};
undo.onLabelUpdate = loadLabelData;
undo.onStateUpdate = (state: UndoState) => {
undoState = state;
};
const deleteSelected = () => {
LabelDesignerUtils.deleteSelection(fabricCanvas!);
discardSelection();
};
const cloneSelected = () => {
LabelDesignerUtils.cloneSelection(fabricCanvas!).then(() => undo.push(fabricCanvas!, labelProps));
};
const moveSelected = (direction: MoveDirection, ctrl?: boolean) => {
LabelDesignerUtils.moveSelection(fabricCanvas!, direction, ctrl);
undo.push(fabricCanvas!, labelProps);
};
const onKeyDown = (e: KeyboardEvent) => {
const key: string = e.key.toLowerCase();
// windows and linux users are used to ctrl, mac users use cmd
const cmdOrCtrl = e.metaKey || e.ctrlKey;
// Esc
if (key === "escape") {
discardSelection();
return;
}
if (LabelDesignerUtils.isAnyInputFocused(fabricCanvas!)) {
return;
}
// Arrows
if (key.startsWith("arrow")) {
moveSelected(key.slice("arrow".length) as MoveDirection, cmdOrCtrl);
return;
}
if (e.repeat) {
return;
}
// Ctrl + D
if (cmdOrCtrl && key === "d") {
e.preventDefault();
cloneSelected();
return;
}
// Ctrl + Y, Ctrl + Shift + Z
if ((cmdOrCtrl && key === "y") || (cmdOrCtrl && e.shiftKey && key === "z")) {
e.preventDefault();
if (!undoState.redoDisabled) {
undo.redo();
}
return;
}
// Ctrl + Z
if (cmdOrCtrl && key === "z") {
e.preventDefault();
if (!undoState.undoDisabled) {
undo.undo();
}
return;
}
// Del
if (key === "delete" || key === "backspace") {
deleteSelected();
return;
}
};
const onUpdateLabelProps = (newProps: LabelProps) => {
labelProps = newProps;
fabricCanvas!.setDimensions(labelProps.size);
fabricCanvas!.virtualZoom(fabricCanvas!.getVirtualZoom());
try {
LocalStoragePersistence.saveLastLabelProps(labelProps);
undo.push(fabricCanvas!, labelProps);
} catch (e) {
Toasts.zodErrors(e, "Label parameters save error:");
}
};
const exportCurrentLabel = (): ExportedLabelTemplate => {
return FileUtils.makeExportedLabel(fabricCanvas!, labelProps, csvEnabled);
};
const onLoadRequested = (label: ExportedLabelTemplate) => {
loadLabelData(label).then(() => undo.push(fabricCanvas!, labelProps));
};
const zplImageReady = async (img: Blob) => {
await LabelDesignerObjectHelper.addImageBlob(fabricCanvas!, img);
undo.push(fabricCanvas!, labelProps);
};
const onObjectPicked = (objectType: OjectType) => {
const obj = LabelDesignerObjectHelper.addObject(fabricCanvas!, objectType);
if (obj !== undefined) {
fabricCanvas!.setActiveObject(obj);
undo.push(fabricCanvas!, labelProps);
}
};
const onIconPicked = (i: MaterialIcon) => {
// todo: icon is not vertically centered
LabelDesignerObjectHelper.addStaticText(fabricCanvas!, String.fromCodePoint(iconCodepoints[i]), {
fontFamily: "Material Icons",
fontSize: 100,
});
undo.push(fabricCanvas!, labelProps);
};
const onSvgIconPicked = (i: string) => {
LabelDesignerObjectHelper.addSvg(fabricCanvas!, i);
undo.push(fabricCanvas!, labelProps);
};
const openPreview = () => {
printNow = false;
previewOpened = true;
};
const openPreviewAndPrint = () => {
printNow = true;
previewOpened = true;
};
const controlValueUpdated = () => {
if (selectedObject) {
selectedObject.setCoords();
selectedObject.dirty = true;
undo.push(fabricCanvas!, labelProps);
}
fabricCanvas!.requestRenderAll();
// trigger reactivity for controls
editRevision++;
};
const getCanvasForPreview = (): FabricJson => {
return fabricCanvas!.toJSON();
};
const onCsvPlaceholderPicked = (name: string) => {
const obj = LabelDesignerObjectHelper.addText(fabricCanvas!, `{${name}}`, {
textAlign: "left",
originX: "left",
originY: "top",
});
fabricCanvas!.setActiveObject(obj);
undo.push(fabricCanvas!, labelProps);
};
const onPaste = async (event: ClipboardEvent) => {
if (LabelDesignerUtils.isAnyInputFocused(fabricCanvas!)) {
return;
}
const openedDropdowns = document.querySelectorAll(".dropdown-menu.show");
if (openedDropdowns.length > 0) {
return;
}
if (event.clipboardData != null) {
event.preventDefault();
const obj = await LabelDesignerObjectHelper.addObjectFromClipboard(fabricCanvas!, event.clipboardData);
if (obj !== undefined) {
fabricCanvas!.setActiveObject(obj);
undo.push(fabricCanvas!, labelProps);
}
}
};
const clearCanvas = () => {
if (!confirm($tr("editor.clear.confirm"))) {
return;
}
undo.push(fabricCanvas!, labelProps);
fabricCanvas!.clear();
};
const loadDefaultLabel = async () => {
try {
const urlTemplate = await FileUtils.readLabelFromUrl();
if (urlTemplate !== null && confirm($tr("params.saved_labels.load.url.warn"))) {
onLoadRequested(urlTemplate);
Toasts.message($tr("params.saved_labels.load.url.loaded"));
return;
}
} catch (e) {
Toasts.error(e);
}
try {
const defaultTemplate = LocalStoragePersistence.loadDefaultTemplate();
if (defaultTemplate !== null) {
onLoadRequested(defaultTemplate);
return;
}
} catch (e) {
Toasts.error(e);
}
LabelDesignerObjectHelper.addText(fabricCanvas!, $tr("editor.default_text"));
};
const renderOnFontsChanged = () => {
fabricCanvas?.forEachObject((o) => {
if (o instanceof fabric.Textbox) {
o.dirty = true;
}
});
fabricCanvas?.requestRenderAll();
};
onMount(async () => {
try {
const savedLabelProps = LocalStoragePersistence.loadLastLabelProps();
if (savedLabelProps !== null) {
labelProps = savedLabelProps;
}
} catch (e) {
Toasts.zodErrors(e, "Label parameters load error:");
}
fabricCanvas = new CustomCanvas(htmlCanvas, {
width: labelProps.size.width,
height: labelProps.size.height,
});
fabricCanvas.setLabelProps(labelProps);
await loadDefaultLabel();
undo.push(fabricCanvas, labelProps);
// force close dropdowns on touch devices
fabricCanvas.on("mouse:down", (): void => {
const dropdowns = document.querySelectorAll("[data-bs-toggle='dropdown']");
dropdowns.forEach((el) => new Dropdown(el).hide());
});
fabricCanvas.on("object:moving", (e): void => {
if (e.target && e.target.left !== undefined && e.target.top !== undefined) {
e.target.set({
left: Math.round(e.target.left / GRID_SIZE) * GRID_SIZE,
top: Math.round(e.target.top / GRID_SIZE) * GRID_SIZE,
});
}
});
fabricCanvas.on("object:modified", (): void => {
undo.push(fabricCanvas!, labelProps);
});
fabricCanvas.on("text:changed", () => {
editRevision++;
});
fabricCanvas.on("object:removed", (): void => {
undo.push(fabricCanvas!, labelProps);
});
fabricCanvas.on("selection:created", (e): void => {
selectedCount = e.selected?.length ?? 0;
selectedObject = e.selected?.length === 1 ? e.selected[0] : undefined;
editRevision++;
});
fabricCanvas.on("selection:updated", (e): void => {
selectedCount = e.selected?.length ?? 0;
selectedObject = e.selected?.length === 1 ? e.selected[0] : undefined;
editRevision++;
});
fabricCanvas.on("selection:cleared", (): void => {
selectedObject = undefined;
selectedCount = 0;
editRevision++;
});
fabricCanvas.on("dragover", (e): void => {
e.e.preventDefault();
});
fabricCanvas.on("drop:after", async (e): Promise<void> => {
const dragEvt = e.e as DragEvent;
dragEvt.preventDefault();
let dropped = false;
if (dragEvt.dataTransfer?.files) {
for (const file of dragEvt.dataTransfer.files) {
try {
await LabelDesignerObjectHelper.addImageFile(fabricCanvas!, file);
dropped = true;
} catch (e) {
Toasts.error(e);
}
}
if (dropped) {
undo.push(fabricCanvas!, labelProps);
}
}
});
fabricCanvas.on("object:scaling", (e): void => {
if (!e.target) {
return;
}
CanvasUtils.fixFabricObjectScale(e.target);
});
// userFonts.subscribe((e) => {console.log(e); renderOnFontsChanged();});
if ($automation !== undefined) {
if ($automation.startPrint !== undefined) {
if ($automation.startPrint === "immediately") {
openPreview();
} else if ($automation.startPrint === "after_connect") {
const unsubscribe = connectionState.subscribe((st) => {
if (st === "connected") {
tick().then(() => unsubscribe());
openPreviewAndPrint();
}
});
}
}
}
});
onDestroy(() => {
fabricCanvas!.dispose();
});
$effect(() => {
fabricCanvas?.setLabelProps(labelProps);
});
$effect(() => {
if (!previewOpened) {
printNow = false;
}
});
$effect(() => {
if ($loadedFonts) {
renderOnFontsChanged();
}
});
</script>
<svelte:window bind:innerWidth={windowWidth} onkeydown={onKeyDown} onpaste={onPaste} />
<div class="image-editor">
<div class="row mb-3">
<div class="col d-flex {windowWidth === 0 || labelProps.size.width < windowWidth ? 'justify-content-center' : ''}">
<div class="canvas-wrapper print-start-{labelProps.printDirection}">
<canvas bind:this={htmlCanvas}></canvas>
</div>
</div>
</div>
<div class="row mb-1">
<div class="col d-flex justify-content-center">
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
<LabelPropsEditor {labelProps} onChange={onUpdateLabelProps} />
<button class="btn btn-sm btn-secondary" onclick={clearCanvas} title={$tr("editor.clear")}>
<MdIcon icon="cancel_presentation" />
</button>
<SavedLabelsMenu
canvas={fabricCanvas!}
onRequestLabelTemplate={exportCurrentLabel}
{onLoadRequested}
{csvEnabled} />
<button
class="btn btn-sm btn-secondary"
disabled={undoState.undoDisabled}
onclick={() => undo.undo()}
title={$tr("editor.undo")}>
<MdIcon icon="undo" />
</button>
<button
class="btn btn-sm btn-secondary"
disabled={undoState.redoDisabled}
onclick={() => undo.redo()}
title={$tr("editor.redo")}>
<MdIcon icon="redo" />
</button>
<CsvControl bind:enabled={csvEnabled} onPlaceholderPicked={onCsvPlaceholderPicked} />
<IconPicker onSubmit={onIconPicked} onSubmitSvg={onSvgIconPicked} />
<ObjectPicker onSubmit={onObjectPicked} {labelProps} {zplImageReady} />
<button class="btn btn-sm btn-primary ms-1" onclick={openPreview}>
<MdIcon icon="visibility" />
{$tr("editor.preview")}
</button>
<button
title="Print with default or saved parameters"
class="btn btn-sm btn-primary ms-1"
onclick={openPreviewAndPrint}
disabled={$connectionState !== "connected"}><MdIcon icon="print" /> {$tr("editor.print")}</button>
</div>
</div>
</div>
<div class="row mb-1">
<div class="col d-flex justify-content-center">
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
{#if selectedCount > 0}
<button class="btn btn-sm btn-danger me-1" onclick={deleteSelected} title={$tr("editor.delete")}>
<MdIcon icon="delete" />
</button>
{/if}
{#if selectedCount > 0}
<button class="btn btn-sm btn-secondary me-1" onclick={cloneSelected} title={$tr("editor.clone")}>
<MdIcon icon="content_copy" />
</button>
{/if}
{#if selectedObject && selectedCount === 1}
<GenericObjectParamsControls {selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject}
<VectorParamsControls {selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof fabric.IText}
<TextParamsControls selectedText={selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof QRCode}
<QrCodeParamsPanel selectedQRCode={selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof Barcode}
<BarcodeParamsPanel selectedBarcode={selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof fabric.IText || selectedObject instanceof QRCode || (selectedObject instanceof Barcode && selectedObject.encoding === "CODE128B")}
<VariableInsertControl {selectedObject} valueUpdated={controlValueUpdated} />
{/if}
</div>
</div>
</div>
{#if previewOpened}
<PrintPreview
bind:show={previewOpened}
canvasCallback={getCanvasForPreview}
{labelProps}
{printNow}
{csvEnabled}
csvData={$csvData.data} />
{/if}
</div>
<style>
.canvas-wrapper {
border: 1px solid rgba(0, 0, 0, 0.4);
background-color: rgba(60, 55, 63, 0.5);
}
.canvas-wrapper.print-start-left {
border-left: 2px solid #ff4646;
}
.canvas-wrapper.print-start-top {
border-top: 2px solid #ff4646;
}
.canvas-wrapper canvas {
image-rendering: pixelated;
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import BrowserWarning from "$/components/basic/BrowserWarning.svelte";
import LabelDesigner from "$/components/LabelDesigner.svelte";
import PrinterConnector from "$/components/PrinterConnector.svelte";
import { locale, locales, tr } from "$/utils/i18n";
import DebugStuff from "$/components/DebugStuff.svelte";
import MdIcon from "$/components/basic/MdIcon.svelte";
// eslint-disable-next-line no-undef
const appCommit = __APP_COMMIT__;
// eslint-disable-next-line no-undef
const buildDate = __BUILD_DATE__;
let debugStuffShow = $state<boolean>(false);
</script>
<div class="container my-2">
<div class="row align-items-center mb-3">
<div class="col">
<h1 class="title">
<img src="{import.meta.env.BASE_URL}logo.png" alt="Fichero" class="logo" />
</h1>
</div>
<div class="col-md-3">
<PrinterConnector />
</div>
</div>
<div class="row">
<div class="col">
<BrowserWarning />
</div>
</div>
<div class="row">
<div class="col">
<LabelDesigner />
</div>
</div>
</div>
<div class="footer text-end text-secondary p-3">
<div>
<select class="form-select form-select-sm text-secondary d-inline-block w-auto" bind:value={$locale}>
{#each Object.entries(locales) as [key, name] (key)}
<option value={key}>{name}</option>
{/each}
</select>
</div>
<div>
{#if appCommit}
<a class="text-secondary" href="https://github.com/mohamedha/fichero-printer/commit/{appCommit}">
{appCommit.slice(0, 6)}
</a>
{/if}
{$tr("main.built")}
{buildDate}
</div>
<div>
<a class="text-secondary" href="https://github.com/mohamedha/fichero-printer">{$tr("main.code")}</a>
<button class="text-secondary btn btn-link p-0" onclick={() => debugStuffShow = true}>
<MdIcon icon="bug_report" />
</button>
</div>
</div>
{#if debugStuffShow}
<DebugStuff bind:show={debugStuffShow} />
{/if}
<style>
.logo {
height: 1.4em;
vertical-align: middle;
margin-right: 0.2em;
border-radius: 4px;
}
.footer {
position: absolute;
bottom: 0;
right: 0;
z-index: -1;
}
@media only screen and (max-device-width: 540px) {
.footer {
position: relative !important;
z-index: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,616 @@
<script lang="ts">
import { onMount } from "svelte";
import { derived } from "svelte/store";
import { appConfig, connectionState, printerClient, printerMeta, refreshRfidInfo } from "$/stores";
import { copyImageData, threshold, atkinson, invert, bayer } from "$/utils/post_process";
import {
type EncodedImage,
ImageEncoder,
LabelType,
printTaskNames,
type PrintProgressEvent,
type PrintTaskName,
AbstractPrintTask,
Utils,
} from "$/lib/fichero";
import type { LabelProps, PostProcessType, FabricJson, PreviewProps, PreviewPropsOffset } from "$/types";
import ParamLockButton from "$/components/basic/ParamLockButton.svelte";
import { tr, type TranslationKey } from "$/utils/i18n";
import { canvasPreprocess } from "$/utils/canvas_preprocess";
import { type DSVRowArray, csvParse } from "d3-dsv";
import { LocalStoragePersistence } from "$/utils/persistence";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { Toasts } from "$/utils/toasts";
import { CustomCanvas } from "$/fabric-object/custom_canvas";
import { FileUtils } from "$/utils/file_utils";
import AppModal from "$/components/basic/AppModal.svelte";
interface Props {
labelProps: LabelProps;
canvasCallback: () => FabricJson;
printNow?: boolean;
csvData: string;
csvEnabled: boolean;
show: boolean;
}
let { labelProps, canvasCallback, printNow = false, csvData, csvEnabled, show = $bindable() }: Props = $props();
let previewCanvas: HTMLCanvasElement;
let printState = $state<"idle" | "sending" | "printing">("idle");
let printProgress = $state<number>(0); // todo: more progress data
let density = $state<number>($printerMeta?.densityDefault ?? 3);
let speed = $state<0 | 1>(1);
let quantity = $state<number>(1);
let postProcessType = $state<PostProcessType>();
let postProcessInvert = $state<boolean>(false);
let thresholdValue = $state<number>(140);
let originalImage: ImageData;
let previewContext: CanvasRenderingContext2D;
let printTaskName = $state<PrintTaskName>("B1");
let labelType = $state<LabelType>(LabelType.WithGaps);
// eslint-disable-next-line no-undef
let statusTimer: NodeJS.Timeout | undefined = undefined;
let error = $state<string>("");
let detectedPrintTaskName: PrintTaskName | undefined = $printerClient?.getPrintTaskType();
let csvParsed: DSVRowArray<string>;
let page = $state<number>(0);
let pagesTotal = $state<number>(1);
let offset = $state<PreviewPropsOffset>({ x: 0, y: 0, offsetType: "inner" });
let offsetWarning = $state<string>("");
let currentPrintTask: AbstractPrintTask | undefined;
let savedProps = $state<PreviewProps>({});
let modalRef: AppModal;
const disconnected = derived(connectionState, ($connectionState) => $connectionState !== "connected");
const labelTypeTranslationKey = (labelType: string): TranslationKey =>
`preview.label_type.${labelType}` as TranslationKey;
const endPrint = async () => {
clearInterval(statusTimer);
if (!$disconnected && printState !== "idle") {
if (currentPrintTask !== undefined) {
await currentPrintTask.printEnd();
} else {
console.warn("Print task undefined, falling back to PrintEnd command");
await $printerClient.abstraction.printEnd();
}
refreshRfidInfo();
$printerClient.startHeartbeat();
}
printState = "idle";
printProgress = 0;
};
const onPrintOnSystemPrinter = async () => {
const sources: string[] = [];
for (let curPage = 0; curPage < pagesTotal; curPage++) {
page = curPage;
await generatePreviewData(page);
sources.push(previewCanvas.toDataURL("image/png"));
}
FileUtils.printImageUrls(sources);
};
const onPrint = async () => {
printState = "sending";
error = "";
// do it in a stupid way (multi-page print not finished yet)
for (let curPage = 0; curPage < pagesTotal; curPage++) {
$printerClient.stopHeartbeat();
currentPrintTask = $printerClient.abstraction.newPrintTask(printTaskName, {
totalPages: quantity,
density,
speed,
labelType,
statusPollIntervalMs: 100,
statusTimeoutMs: 8_000,
});
page = curPage;
console.log("Printing page", page);
await generatePreviewData(page);
try {
const encoded: EncodedImage = ImageEncoder.encodeCanvas(previewCanvas, labelProps.printDirection);
await currentPrintTask.printInit();
await currentPrintTask.printPage(encoded, quantity);
} catch (e) {
error = `${e}`;
console.error(e);
return;
}
printState = "printing";
const listener = (e: PrintProgressEvent) => {
printProgress = Math.floor((e.page / quantity) * ((e.pagePrintProgress + e.pageFeedProgress) / 2));
};
$printerClient.on("printprogress", listener);
try {
await currentPrintTask.waitForFinished();
} catch (e) {
error = `${e}`;
console.error(e);
}
$printerClient.off("printprogress", listener);
await endPrint();
if (
$appConfig.pageDelay !== undefined &&
$appConfig.pageDelay > 0 &&
pagesTotal > 1 &&
curPage < pagesTotal - 1
) {
await Utils.sleep($appConfig.pageDelay);
}
}
printState = "idle";
$printerClient.startHeartbeat();
if (printNow && !error) {
modalRef.hide();
}
};
const updatePreview = () => {
let iData: ImageData = copyImageData(originalImage);
if (postProcessType === "threshold") {
iData = threshold(iData, thresholdValue);
} else if (postProcessType === "dither") {
iData = atkinson(iData, thresholdValue);
} else if (postProcessType === "bayer") {
iData = bayer(iData, thresholdValue);
}
if (postProcessInvert) {
iData = invert(iData);
}
offsetWarning = "";
if (offset.offsetType === "inner") {
previewCanvas.width = originalImage.width;
previewCanvas.height = originalImage.height;
previewContext.fillStyle = "white";
previewContext.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
previewContext.putImageData(iData, offset.x, offset.y);
} else {
previewCanvas.width = originalImage.width + Math.abs(offset.x);
previewCanvas.height = originalImage.height + Math.abs(offset.y);
previewContext.fillStyle = "white";
previewContext.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
previewContext.putImageData(iData, Math.max(offset.x, 0), Math.max(offset.y, 0));
}
if ($printerMeta !== undefined) {
const headSize = labelProps.printDirection == "left" ? previewCanvas.height : previewCanvas.width;
if (headSize > $printerMeta.printheadPixels) {
offsetWarning += $tr("params.label.warning.width") + " ";
offsetWarning += `(${headSize} > ${$printerMeta.printheadPixels})`;
offsetWarning += "\n";
}
}
};
const toggleSavedProp = (key: string, value: any) => {
const keyObj = key as keyof typeof savedProps;
savedProps[keyObj] = savedProps[keyObj] === undefined ? value : undefined;
try {
LocalStoragePersistence.savePreviewProps(savedProps);
} catch (e) {
Toasts.zodErrors(e, "Preview parameters save error:");
}
};
const updateSavedProp = (key: string, value: any, refreshPreview: boolean = false) => {
const keyObj = key as keyof typeof savedProps;
if (savedProps[keyObj] !== undefined) {
savedProps[keyObj] = value;
try {
LocalStoragePersistence.savePreviewProps(savedProps);
} catch (e) {
Toasts.zodErrors(e, "Preview parameters save error:");
}
}
if (refreshPreview) {
updatePreview();
}
};
const loadProps = () => {
try {
const saved = LocalStoragePersistence.loadSavedPreviewProps();
if (saved === null) {
return;
}
savedProps = saved;
if (saved.postProcess !== undefined) postProcessType = saved.postProcess;
if (saved.postProcessInvert !== undefined) postProcessInvert = saved.postProcessInvert;
if (saved.threshold !== undefined) thresholdValue = saved.threshold;
if (saved.quantity !== undefined) quantity = saved.quantity;
if (saved.density !== undefined) density = saved.density;
if (saved.speed !== undefined) speed = saved.speed;
if (saved.labelType !== undefined) labelType = saved.labelType;
if (saved.printTaskName !== undefined) printTaskName = saved.printTaskName;
if (saved.offset !== undefined) offset = saved.offset;
} catch (e) {
Toasts.zodErrors(e, "Preview parameters load error:");
}
};
const pageDown = () => {
if (!csvEnabled) {
page = 0;
return;
}
page = Math.max(0, Math.min(csvParsed.length - 1, page - 1));
generatePreviewData(page);
};
const pageUp = () => {
if (!csvEnabled) {
page = 0;
return;
}
page = Math.min(csvParsed.length - 1, page + 1);
generatePreviewData(page);
};
const generatePreviewData = async (page: number): Promise<void> => {
const fabricTempCanvas = new CustomCanvas(undefined, {
width: labelProps.size.width,
height: labelProps.size.height,
});
fabricTempCanvas.setCustomBackground(false);
fabricTempCanvas.setHighlightMirror(false);
fabricTempCanvas.setLabelProps(labelProps);
await fabricTempCanvas.loadFromJSON(canvasCallback());
let variables = {};
if (csvEnabled) {
if (page >= 0 && page < csvParsed.length) {
variables = csvParsed[page];
} else {
console.warn(`Page ${page} is out of csv bounds (csv length is ${csvParsed.length})`);
}
}
console.log("Page variables:", variables);
canvasPreprocess(fabricTempCanvas, variables);
await fabricTempCanvas.createMirroredObjects();
fabricTempCanvas.requestRenderAll();
const preRenderedCanvas = fabricTempCanvas.toCanvasElement();
const ctx = preRenderedCanvas.getContext("2d")!;
previewCanvas.width = preRenderedCanvas.width;
previewCanvas.height = preRenderedCanvas.height;
previewContext = previewCanvas.getContext("2d")!;
originalImage = ctx.getImageData(0, 0, preRenderedCanvas.width, preRenderedCanvas.height);
updatePreview();
fabricTempCanvas.dispose();
};
const onModalClose = () => {
endPrint();
};
onMount(async () => {
if (csvEnabled) {
const parseResult = csvParse(csvData);
const spread: DSVRowArray<string> = Object.assign([], { columns: parseResult.columns });
for (let row of parseResult) {
for (const k of Object.keys(row)) {
row[k] = row[k].replaceAll("\\n", "\n");
}
let times = 1;
if ("$times" in row && row["$times"] !== "") {
try {
times = parseInt(row["$times"]);
} catch (e) {
console.warn("$times parse error", e);
}
}
if (times < 0) {
times = 0;
}
for (let i = 0; i < times; i++) {
spread.push(row);
}
}
csvParsed = spread;
pagesTotal = csvParsed.length;
}
if (detectedPrintTaskName !== undefined) {
console.log(`Detected print task version: ${detectedPrintTaskName}`);
printTaskName = detectedPrintTaskName;
}
loadProps();
await generatePreviewData(page);
if (printNow && !$disconnected && printState === "idle") {
onPrint();
}
});
</script>
<AppModal title={$tr("preview.title")} onClose={onModalClose} bind:show bind:this={modalRef}>
<div class="d-flex justify-content-center">
{#if pagesTotal > 1}
<button disabled={printState !== "idle"} class="btn w-100 fs-1" onclick={pageDown}>
<MdIcon icon="chevron_left" />
</button>
{/if}
<canvas class="print-start-{labelProps.printDirection}" bind:this={previewCanvas}></canvas>
{#if pagesTotal > 1}
<button disabled={printState !== "idle"} class="btn w-100 fs-1" onclick={pageUp}>
<MdIcon icon="chevron_right" />
</button>
{/if}
</div>
<div class="text-center">
{#if pagesTotal > 1}<div>Page {page + 1} / {pagesTotal}</div>{/if}
{#if printState === "sending"}
<div>Sending...</div>
{/if}
{#if printState === "printing"}
<div>
Printing...
<div class="progress" role="progressbar">
<div class="progress-bar" style="width: {printProgress}%">{printProgress}%</div>
</div>
</div>
{/if}
{#if error}
<div class="alert alert-danger" role="alert">{error}</div>
{/if}
</div>
{#snippet footer()}
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.postprocess")}</span>
<select
class="form-select"
bind:value={postProcessType}
onchange={() => updateSavedProp("postProcess", postProcessType, true)}>
<option value="threshold">{$tr("preview.postprocess.threshold")}</option>
<option value="dither">{$tr("preview.postprocess.atkinson")}</option>
<option value="bayer">{$tr("preview.postprocess.bayer")}</option>
</select>
<ParamLockButton
propName="postProcess"
value={postProcessType}
savedValue={savedProps.postProcess}
onClick={toggleSavedProp} />
<button
class="btn btn-sm {postProcessInvert ? 'btn-secondary' : 'btn-outline-secondary'}"
onclick={() => {
postProcessInvert = !postProcessInvert;
updatePreview();
}}>
<MdIcon icon="invert_colors" />
</button>
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.threshold")}</span>
<input
type="range"
id="threshold"
class="form-range"
min="1"
max="255"
bind:value={thresholdValue}
onchange={() => updateSavedProp("threshold", thresholdValue, true)} />
<span class="input-group-text">{thresholdValue}</span>
<ParamLockButton
propName="threshold"
value={thresholdValue}
savedValue={savedProps.threshold}
onClick={toggleSavedProp} />
</div>
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text">{$tr("preview.copies")}</span>
<input
class="form-control"
type="number"
min="1"
bind:value={quantity}
onchange={() => updateSavedProp("quantity", quantity)} />
<ParamLockButton
propName="quantity"
value={quantity}
savedValue={savedProps.quantity}
onClick={toggleSavedProp} />
</div>
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text">{$tr("preview.density")}</span>
<input
class="form-control"
type="number"
min={$printerMeta?.densityMin ?? 1}
max={$printerMeta?.densityMax ?? 20}
bind:value={density}
onchange={() => updateSavedProp("density", density)} />
<ParamLockButton propName="density" value={density} savedValue={savedProps.density} onClick={toggleSavedProp} />
</div>
{#if printTaskName === "D110M_V4"}
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text">{$tr("preview.speed")}</span>
<select class="form-select" bind:value={speed} onchange={() => updateSavedProp("speed", speed, true)}>
<option value={0}>{$tr("preview.speed.0")}</option>
<option value={1}>{$tr("preview.speed.1")}</option>
</select>
<ParamLockButton propName="speed" value={speed} savedValue={savedProps.speed} onClick={toggleSavedProp} />
</div>
{/if}
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.label_type")}</span>
<select class="form-select" bind:value={labelType} onchange={() => updateSavedProp("labelType", labelType)}>
{#each Object.values(LabelType) as lt (lt)}
{#if typeof lt !== "string"}
<option value={lt}>
{#if $printerMeta?.paperTypes.includes(lt)}{/if}
{$tr(labelTypeTranslationKey(LabelType[lt]))}
</option>
{/if}
{/each}
</select>
<ParamLockButton
propName="labelType"
value={labelType}
savedValue={savedProps.labelType}
onClick={toggleSavedProp} />
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.print_task")}</span>
<select
class="form-select"
bind:value={printTaskName}
onchange={() => updateSavedProp("printTaskName", printTaskName)}>
{#each printTaskNames as name (name)}
<option value={name}>
{#if detectedPrintTaskName === name}{/if}
{name}
</option>
{/each}
</select>
<ParamLockButton
propName="printTaskName"
value={printTaskName}
savedValue={savedProps.printTaskName}
onClick={toggleSavedProp} />
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.offset")}</span>
{#if offsetWarning}
<span class="input-group-text text-warning" title={offsetWarning}><MdIcon icon="warning" /></span>
{/if}
<span class="input-group-text"><MdIcon icon="unfold_more" class="r-90" /></span>
<input
class="form-control"
type="number"
bind:value={offset.x}
onchange={() => updateSavedProp("offset", offset, true)} />
<span class="input-group-text"><MdIcon icon="unfold_more" /></span>
<input
class="form-control"
type="number"
bind:value={offset.y}
onchange={() => updateSavedProp("offset", offset, true)} />
<select
class="form-select"
bind:value={offset.offsetType}
onchange={() => updateSavedProp("offset", offset, true)}>
<option value="inner">{$tr("preview.offset.inner")}</option>
<option value="outer">{$tr("preview.offset.outer")}</option>
</select>
<ParamLockButton propName="offset" value={offset} savedValue={savedProps.offset} onClick={toggleSavedProp} />
</div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{$tr("preview.close")}</button>
{#if printState !== "idle"}
<button type="button" class="btn btn-primary" disabled={$disconnected} onclick={endPrint}>
{$tr("preview.print.cancel")}
</button>
{/if}
<button
type="button"
class="btn btn-secondary"
title={$tr("preview.print.system")}
onclick={onPrintOnSystemPrinter}>
<MdIcon icon="print" />
</button>
<button type="button" class="btn btn-primary" disabled={$disconnected || printState !== "idle"} onclick={onPrint}>
{#if $disconnected}
{$tr("preview.not_connected")}
{:else}
<MdIcon icon="print" /> {$tr("preview.print")}
{/if}
</button>
{/snippet}
</AppModal>
<style>
canvas {
image-rendering: pixelated;
border: 1px solid #6d6d6d;
max-width: 100%;
}
canvas.print-start-left {
border-left: 2px solid #ff4646;
}
canvas.print-start-top {
border-top: 2px solid #ff4646;
}
.progress-bar {
transition: none;
}
.input-group .form-range {
flex-grow: 1;
width: 1%;
height: unset;
padding: 0 1rem;
}
</style>

View File

@@ -0,0 +1,235 @@
<script lang="ts">
import { SoundSettingsItemType, Utils, type AvailableTransports } from "$/lib/fichero";
import {
printerClient,
connectedPrinterName,
connectionState,
initClient,
heartbeatData,
printerInfo,
printerMeta,
heartbeatFails,
rfidInfo,
ribbonRfidInfo,
refreshRfidInfo,
} from "$/stores";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { Toasts } from "$/utils/toasts";
import { onMount } from "svelte";
import type { MaterialIcon } from "material-icons";
import FirmwareUpdater from "$/components/basic/FirmwareUpdater.svelte";
let featureSupport = $state<AvailableTransports>({ webBluetooth: false });
const onConnectClicked = async () => {
initClient();
connectionState.set("connecting");
try {
await $printerClient.connect();
} catch (e) {
connectionState.set("disconnected");
Toasts.error(e);
}
};
const onDisconnectClicked = () => {
$printerClient.disconnect();
};
const startHeartbeat = async () => {
$printerClient.startHeartbeat();
};
const stopHeartbeat = async () => {
$printerClient.stopHeartbeat();
};
const soundOn = async () => {
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.BluetoothConnectionSound, true);
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.PowerSound, true);
};
const soundOff = async () => {
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.BluetoothConnectionSound, false);
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.PowerSound, false);
};
const fetchInfo = async () => {
await $printerClient.fetchPrinterInfo();
};
const reset = async () => {
await $printerClient.abstraction.printerReset();
};
const batteryIcon = (value: number): MaterialIcon => {
if (value > 4) {
value = Math.min(4, Math.max(1, Math.ceil(value / 25)));
}
if (value === 4) {
return "battery_full";
} else if (value === 3) {
return "battery_5_bar";
} else if (value === 2) {
return "battery_3_bar";
} else if (value === 1) {
return "battery_2_bar";
}
return "battery_0_bar";
};
onMount(() => {
featureSupport = Utils.getAvailableTransports();
});
</script>
<div class="input-group w-auto input-group-sm flex-nowrap justify-content-end">
{#if $connectionState === "connected"}
<button class="btn btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="settings" />
</button>
<div class="dropdown-menu p-1">
{#if $printerInfo}
<div>
Printer info:
<ul>
{#each Object.entries($printerInfo) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $printerMeta}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#modelMeta">
Model metadata <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="modelMeta">
<ul>
{#each Object.entries($printerMeta) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $rfidInfo}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#rfidInfo">
RFID info <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="rfidInfo">
<button class="btn btn-outline-secondary btn-sm mt-1" onclick={refreshRfidInfo}>Update</button>
<ul>
{#each Object.entries($rfidInfo) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $ribbonRfidInfo}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#ribbonRfidInfo">
Ribbon RFID info <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="ribbonRfidInfo">
<button class="btn btn-outline-secondary btn-sm mt-1" onclick={refreshRfidInfo}>Update</button>
<ul>
{#each Object.entries($ribbonRfidInfo) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $heartbeatData}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#heartbeatData">
Heartbeat data <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="heartbeatData">
<ul>
{#each Object.entries($heartbeatData) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
<FirmwareUpdater />
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tests">
Tests <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="tests">
<div class="d-flex flex-wrap gap-1 mt-1">
<button class="btn btn-sm btn-primary" onclick={startHeartbeat}>Heartbeat on</button>
<button class="btn btn-sm btn-primary" onclick={stopHeartbeat}>Heartbeat off</button>
<button class="btn btn-sm btn-primary" onclick={soundOn}>Sound on</button>
<button class="btn btn-sm btn-primary" onclick={soundOff}>Sound off</button>
<button class="btn btn-sm btn-primary" onclick={fetchInfo}>Fetch info again</button>
<button class="btn btn-sm btn-primary" onclick={reset}>Reset</button>
</div>
</div>
</div>
<span class="input-group-text">
<MdIcon icon="bluetooth" />
</span>
<span class="input-group-text {$heartbeatFails > 0 ? 'text-warning' : ''}">
{$printerMeta?.model ?? $connectedPrinterName}
</span>
{#if $heartbeatData?.chargeLevel}
<span class="input-group-text">
<MdIcon icon={batteryIcon($heartbeatData.chargeLevel)} class="r-90"></MdIcon>
</span>
{/if}
{:else}
<button
class="btn btn-primary"
disabled={$connectionState === "connecting" || !featureSupport.webBluetooth}
onclick={onConnectClicked}>
<MdIcon icon="bluetooth" />
</button>
{/if}
{#if $connectionState === "connected"}
<button class="btn btn-danger" onclick={onDisconnectClicked}>
<MdIcon icon="power_off" />
</button>
{/if}
</div>
<style>
.dropdown-menu {
width: 100vw;
max-width: 300px;
}
</style>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-21.444 -18.585)"><path transform="rotate(90)" d="m44.437-102.88h30.674c5.3533 0 9.663 4.3097 9.663 9.663v61.38c0 5.3533-4.3097 9.663-9.663 9.663h-30.674c-5.3533 0-9.663-4.3097-9.663-9.663v-61.38c0-5.3533 4.3097-9.663 9.663-9.663z" fill="#fbfbfb" stroke-linecap="round" stroke-width="1.2811"/><path d="m62.523 34.774v50" fill="#ff922a" stroke="#797979" stroke-dasharray="5.29199982,2.64599991" stroke-width="1.323" style="paint-order:stroke markers fill"/><g transform="matrix(-.063538 0 0 .063538 58.461 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g><g transform="matrix(-.063538 0 0 .063538 99.736 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g></g><style type="text/css">
.st0{fill:#000000;}
</style></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-21.444 -18.585)"><path transform="rotate(90)" d="m44.437-102.88h30.674c5.3533 0 9.663 4.3097 9.663 9.663v61.38c0 5.3533-4.3097 9.663-9.663 9.663h-30.674c-5.3533 0-9.663-4.3097-9.663-9.663v-61.38c0-5.3533 4.3097-9.663 9.663-9.663z" fill="#fbfbfb" stroke-linecap="round" stroke-width="1.2811"/><path d="m62.523 34.774v50" fill="#ff922a" stroke="#797979" stroke-dasharray="5.29199982,2.64599991" stroke-width="1.323" style="paint-order:stroke markers fill"/><g transform="matrix(-.063538 0 0 .063538 58.461 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g><g transform="matrix(.063538 0 0 -.063538 67.169 62.608)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g></g><style type="text/css">
.st0{fill:#000000;}
</style></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-21.444 -18.585)"><path transform="rotate(90)" d="m44.437-102.88h30.674c5.3533 0 9.663 4.3097 9.663 9.663v61.38c0 5.3533-4.3097 9.663-9.663 9.663h-30.674c-5.3533 0-9.663-4.3097-9.663-9.663v-61.38c0-5.3533 4.3097-9.663 9.663-9.663z" fill="#fbfbfb" stroke-linecap="round" stroke-width="1.2811"/><path d="m62.523 34.774v50" fill="#ff922a" stroke="#797979" stroke-dasharray="5.29199982,2.64599991" stroke-width="1.323" style="paint-order:stroke markers fill"/><g transform="matrix(-.063538 0 0 .063538 58.461 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g></g><style type="text/css">
.st0{fill:#000000;}
</style></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="34.269" y="34.711" width="50" height="49.747" ry="8.5343" fill="#f00"/>
<rect x="40.09" y="34.585" width="50" height="50" ry="8.5776" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="37.444" y="34.585" width="50" height="50" ry="25" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="37.444" y="34.585" width="50" height="50" ry="0" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="37.444" y="34.585" width="50" height="50" ry="8.5776" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)" fill="#fbfbfb">
<rect x="37.444" y="60.568" width="50" height="24.096" ry="8.5776"/>
<rect x="37.444" y="34.885" width="50" height="24.096" ry="8.5776"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="21.623" y="34.585" width="50" height="50" ry="8.5776" fill="#fbfbfb"/>
<rect x="71.623" y="51.365" width="31.451" height="16.44" ry="0" fill="#ea868f"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import { onDestroy, onMount, type Snippet } from "svelte";
interface Props {
show: boolean;
title: string;
onClose?: () => void;
children: Snippet;
footer?: Snippet;
}
let { show = $bindable(), title, onClose, children, footer }: Props = $props();
let modalEl: HTMLElement;
let modal: Modal;
onMount(() => {
modal = new Modal(modalEl);
modal.show();
modalEl.addEventListener('hide.bs.modal', () => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
});
modalEl.addEventListener("hidden.bs.modal", () => {
if (onClose) onClose();
show = false;
});
});
onDestroy(() => {
if (modal) {
modal.hide();
modal.dispose();
}
});
export const hide = () => {
if (modal) {
modal.hide();
}
};
</script>
<div bind:this={modalEl} class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">{title}</h1>
<button aria-label="Dismiss" type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{@render children()}
</div>
{#if footer}
<div class="modal-footer">
{@render footer()}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Utils } from "$/lib/fichero";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { detectAntiFingerprinting } from "$/utils/browsers";
let caps = Utils.getAvailableTransports();
let antiFingerprinting = detectAntiFingerprinting();
let isMobile = typeof navigator !== "undefined" && /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
</script>
{#if !caps.webBluetooth}
<div class="alert alert-danger text-center" role="alert">
{#if isMobile}
<MdIcon icon="computer" />
Open on a desktop browser (Chrome, Edge, or Opera) to connect to your printer.
{:else}
<div>
{$tr("browser_warning.lines.first")}
</div>
<div>
{$tr("browser_warning.lines.second")}
</div>
{/if}
</div>
{/if}
{#if antiFingerprinting}
<div class="alert alert-danger" role="alert">
{$tr("browser_warning.fingerprinting")}
</div>
{/if}
<style>
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import type { FirmwareProgressEvent } from "$/lib/fichero";
import { printerClient } from "$/stores";
import { Toasts } from "$/utils/toasts";
import { FileUtils } from "$/utils/file_utils";
let fwVersion = $state<string>("");
let fwVersionValid: boolean = $derived(/^\d+\.\d+$/.test(fwVersion));
let fwProgress = $state<string>("");
let fwData = $state<Uint8Array>();
let fwName = $state<string>("");
const browseFw = async () => {
const file = await FileUtils.pickAndReadBinaryFile("bin");
fwData = new Uint8Array(file.data);
fwName = file.name;
const match = fwName.match(/(\d+\.\d+)/);
// For modern firmware images version is stored in header
if (fwData.length >= 0x1C && fwData[0] === 0x18) {
const verNumber = (fwData[0x15] << 8) + fwData[0x14];
fwVersion = (verNumber / 100).toFixed(2);
} else if (match) {
fwVersion = match[1];
} else {
fwVersion = "";
}
};
const upgradeFw = async () => {
if (fwData === undefined) {
return;
}
if (!confirm("Flashing wrong firmware can make your printer dead. Are you sure?")) {
return;
}
const listener = (e: FirmwareProgressEvent) => {
fwProgress = `${e.currentChunk}/${e.totalChunks}`;
};
$printerClient.stopHeartbeat();
try {
$printerClient.on("firmwareprogress", listener);
fwProgress = "...";
await $printerClient.abstraction.firmwareUpgrade(fwData, fwVersion);
$printerClient.off("firmwareprogress", listener);
await $printerClient.disconnect();
Toasts.message("Flashing is finished, the printer will shut down now");
fwData = undefined;
fwName = "";
fwVersion = "";
} catch (e) {
$printerClient.startHeartbeat();
$printerClient.off("firmwareprogress", listener);
Toasts.error(e);
}
fwProgress = "";
};
</script>
<div class="firmware-updater">
Firmware flashing
<div class="input-group input-group-sm mt-1">
{#if fwProgress}
<span class="input-group-text">Uploading {fwProgress}</span>
{:else}
<span class="input-group-text">To</span>
<button class="btn btn-sm btn-secondary" title={fwName} onclick={browseFw} disabled={!!fwProgress}>
{fwName.length > 0 ? fwName.slice(0, 8) + "..." : "Browse..."}
</button>
<span class="input-group-text">ver.</span>
<input class="form-control" placeholder="x.x" type="text" size="6" bind:value={fwVersion} />
<button
class="btn btn-sm btn-danger"
onclick={upgradeFw}
disabled={!!fwProgress || !fwVersionValid || fwData === undefined}>Burn</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { iconCodepoints, type MaterialIcon } from "$/styles/mdi_icons";
interface Props {
icon: MaterialIcon;
class?: string;
}
let { icon, class: className = "" }: Props = $props();
</script>
<span class="mdi {className}">
{String.fromCodePoint(iconCodepoints[icon])}
</span>
<style>
.mdi {
font-family: "Material Icons";
font-weight: normal;
font-style: normal;
line-height: 1;
font-size: 1.5em;
vertical-align: -0.24em;
/*vertical-align: middle;*/
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "liga";
}
.mdi.r-90 {
transform: rotate(90deg);
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
propName: string;
savedValue: any;
value: any;
onClick: (key: string, value: any) => void;
}
let {
propName,
savedValue,
value,
onClick
}: Props = $props();
</script>
<button class="btn btn-sm btn-outline-secondary param-lock-btn" onclick={() => onClick(propName, value)}>
{#if savedValue !== undefined}
<MdIcon icon="lock" class="text-warning" />
{:else}
<MdIcon icon="lock_open" />
{/if}
</button>

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>