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