mirror of
https://github.com/MultiMote/niimblue
synced 2026-01-19 19:37:11 +03:00
521 lines
17 KiB
Svelte
521 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { fabric } from "fabric";
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { derived } from "svelte/store";
|
|
import Modal from "bootstrap/js/dist/modal";
|
|
import { connectionState, printerClient, printerMeta } from "../stores";
|
|
import { copyImageData, threshold, atkinson } from "../utils/post_process";
|
|
import {
|
|
type EncodedImage,
|
|
ImageEncoder,
|
|
LabelType,
|
|
printTaskNames,
|
|
type PrintProgressEvent,
|
|
type PrintTaskName,
|
|
} from "@mmote/niimbluelib";
|
|
import type { LabelProps, PostProcessType, FabricJson, PreviewProps, PreviewPropsOffset } from "../types";
|
|
import ParamLockButton from "./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 "./MdIcon.svelte";
|
|
import { Toasts } from "../utils/toasts";
|
|
|
|
export let onClosed: () => void;
|
|
export let labelProps: LabelProps;
|
|
export let canvasCallback: () => FabricJson;
|
|
export let printNow: boolean = false;
|
|
export let csvData: string;
|
|
export let csvEnabled: boolean;
|
|
|
|
let modalElement: HTMLElement;
|
|
let previewCanvas: HTMLCanvasElement;
|
|
let printState: "idle" | "sending" | "printing" = "idle";
|
|
let modal: Modal;
|
|
let printProgress: number = 0; // todo: more progress data
|
|
let density: number = $printerMeta?.densityDefault ?? 3;
|
|
let quantity: number = 1;
|
|
let postProcessType: PostProcessType;
|
|
let thresholdValue: number = 140;
|
|
let originalImage: ImageData;
|
|
let previewContext: CanvasRenderingContext2D;
|
|
let printTaskName: PrintTaskName = "D110";
|
|
let labelType: LabelType = LabelType.WithGaps;
|
|
let statusTimer: NodeJS.Timeout | undefined = undefined;
|
|
let error: string = "";
|
|
let detectedPrintTaskName: PrintTaskName | undefined = $printerClient?.getPrintTaskType();
|
|
let csvParsed: DSVRowArray<string>;
|
|
let page: number = 0;
|
|
let pagesTotal: number = 1;
|
|
let offset: PreviewPropsOffset = {x: 0, y: 0, offsetType: "inner"};
|
|
let offsetWarning: string = "";
|
|
|
|
let savedProps = {} as PreviewProps;
|
|
|
|
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") {
|
|
await $printerClient.abstraction.printEnd();
|
|
$printerClient.startHeartbeat();
|
|
}
|
|
|
|
printState = "idle";
|
|
printProgress = 0;
|
|
};
|
|
|
|
const onPrint = async () => {
|
|
printState = "sending";
|
|
error = "";
|
|
$printerClient.stopHeartbeat();
|
|
|
|
// do it in a stupid way (multi-page print not finished yet)
|
|
for (let curPage = 0; curPage < pagesTotal; curPage++) {
|
|
const printTask = $printerClient.abstraction.newPrintTask(printTaskName, {
|
|
totalPages: quantity,
|
|
density,
|
|
labelType,
|
|
statusPollIntervalMs: 100,
|
|
statusTimeoutMs: 8_000,
|
|
});
|
|
|
|
page = curPage;
|
|
console.log("Printing page", page);
|
|
|
|
await generatePreviewData(page);
|
|
const encoded: EncodedImage = ImageEncoder.encodeCanvas(previewCanvas, labelProps.printDirection);
|
|
|
|
try {
|
|
await printTask.printInit();
|
|
await printTask.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 printTask.waitForFinished();
|
|
} catch (e) {
|
|
error = `${e}`;
|
|
console.error(e);
|
|
}
|
|
|
|
$printerClient.off("printprogress", listener);
|
|
|
|
await endPrint();
|
|
}
|
|
|
|
printState = "idle";
|
|
$printerClient.startHeartbeat();
|
|
|
|
if (printNow && !error) {
|
|
modal.hide();
|
|
}
|
|
};
|
|
|
|
const updatePreview = () => {
|
|
let iData: ImageData = copyImageData(originalImage);
|
|
|
|
if (postProcessType === "threshold") {
|
|
iData = threshold(iData, thresholdValue);
|
|
} else if (postProcessType === "dither") {
|
|
iData = atkinson(iData, thresholdValue);
|
|
}
|
|
|
|
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) postProcessType = saved.postProcess;
|
|
if (saved.threshold) thresholdValue = saved.threshold;
|
|
if (saved.quantity) quantity = saved.quantity;
|
|
if (saved.density) density = saved.density;
|
|
if (saved.labelType) labelType = saved.labelType;
|
|
if (saved.printTaskName) printTaskName = saved.printTaskName;
|
|
if (saved.offset) 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> => {
|
|
return new Promise((resolve) => {
|
|
const fabricTempCanvas = new fabric.Canvas(null, { width: labelProps.size.width, height: labelProps.size.height });
|
|
|
|
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);
|
|
|
|
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();
|
|
resolve();
|
|
});
|
|
});
|
|
};
|
|
|
|
onMount(async () => {
|
|
if (csvEnabled) {
|
|
csvParsed = csvParse(csvData);
|
|
pagesTotal = csvParsed.length;
|
|
}
|
|
|
|
modal = new Modal(modalElement);
|
|
modal.show();
|
|
modalElement.addEventListener("hidden.bs.modal", async () => {
|
|
endPrint();
|
|
onClosed();
|
|
});
|
|
|
|
if (detectedPrintTaskName !== undefined) {
|
|
console.log(`Detected print task version: ${detectedPrintTaskName}`);
|
|
printTaskName = detectedPrintTaskName;
|
|
}
|
|
|
|
loadProps();
|
|
|
|
await generatePreviewData(page);
|
|
|
|
if (printNow && !$disconnected && printState === "idle") {
|
|
onPrint();
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (modal) {
|
|
modal.hide();
|
|
modal.dispose();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div bind:this={modalElement} class="modal fade" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h1 class="modal-title fs-5">{$tr("preview.title")}</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
|
|
<div class="modal-body text-center">
|
|
<div class="d-flex justify-content-center">
|
|
{#if pagesTotal > 1}
|
|
<button disabled={printState !== "idle"} class="btn w-100 fs-1" on:click={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" on:click={pageUp}>
|
|
<MdIcon icon="chevron_right" />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{#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}
|
|
</div>
|
|
|
|
{#if error}
|
|
<div class="alert alert-danger" role="alert">{error}</div>
|
|
{/if}
|
|
|
|
<div class="modal-footer">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">{$tr("preview.postprocess")}</span>
|
|
|
|
<select
|
|
class="form-select"
|
|
bind:value={postProcessType}
|
|
on:change={() => updateSavedProp("postProcess", postProcessType, true)}>
|
|
<option value="threshold">{$tr("preview.postprocess.threshold")}</option>
|
|
<option value="dither">{$tr("preview.postprocess.atkinson")}</option>
|
|
</select>
|
|
|
|
<ParamLockButton
|
|
propName="postProcess"
|
|
value={postProcessType}
|
|
savedValue={savedProps.postProcess}
|
|
onClick={toggleSavedProp} />
|
|
</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}
|
|
on:change={() => 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}
|
|
on:change={() => 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}
|
|
on:change={() => updateSavedProp("density", density)} />
|
|
<ParamLockButton propName="density" value={density} savedValue={savedProps.density} onClick={toggleSavedProp} />
|
|
</div>
|
|
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">{$tr("preview.label_type")}</span>
|
|
<select class="form-select" bind:value={labelType} on:change={() => updateSavedProp("labelType", labelType)}>
|
|
{#each Object.values(LabelType) as 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}
|
|
on:change={() => updateSavedProp("printTaskName", printTaskName)}>
|
|
{#each printTaskNames as 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}
|
|
on:change={() => updateSavedProp("offset", offset, true)} />
|
|
<span class="input-group-text"><MdIcon icon="unfold_more" /></span>
|
|
<input
|
|
class="form-control"
|
|
type="number"
|
|
bind:value={offset.y}
|
|
on:change={() => updateSavedProp("offset", offset, true)} />
|
|
<select
|
|
class="form-select"
|
|
bind:value={offset.offsetType}
|
|
on:change={() => 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} on:click={endPrint}>
|
|
{$tr("preview.print.cancel")}
|
|
</button>
|
|
{/if}
|
|
|
|
<button type="button" class="btn btn-primary" disabled={$disconnected || printState !== "idle"} on:click={onPrint}>
|
|
{#if $disconnected}
|
|
{$tr("preview.not_connected")}
|
|
{:else}
|
|
<MdIcon icon="print" /> {$tr("preview.print")}
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|