1
0
mirror of https://github.com/MultiMote/niimblue synced 2026-01-19 19:37:11 +03:00

New save/load menu (#39)

This commit is contained in:
MultiMote
2024-10-30 22:31:31 +03:00
committed by GitHub
parent 44a176bd37
commit 47dcc7e0c9
14 changed files with 469 additions and 135 deletions

View File

@@ -50,3 +50,9 @@ export const OBJECT_DEFAULTS_TEXT = {
originY: "center",
lineHeight: 1,
};
/** Scale image to this height when making a label thumbnail */
export const THUMBNAIL_HEIGHT = 48;
/** Generate thumbnail in jpeg format with this quality */
export const THUMBNAIL_QUALITY = 0.7;

View File

@@ -7,7 +7,6 @@
import { iconCodepoints, type MaterialIcon } from "../mdi_icons";
import { connectionState } from "../stores";
import {
ExportedLabelTemplateSchema,
type ExportedLabelTemplate,
type FabricJson,
type LabelProps,
@@ -31,9 +30,9 @@
import QrCodeParamsPanel from "./QRCodeParamsControls.svelte";
import TextParamsPanel from "./TextParamsControls.svelte";
import VariableInsertControl from "./VariableInsertControl.svelte";
import ZplImportButton from "./ZplImportButton.svelte";
import { DEFAULT_LABEL_PROPS, GRID_SIZE } from "../defaults";
import { ImageEditorUtils } from "../utils/image_editor_utils";
import SavedLabelsMenu from "./SavedLabelsMenu.svelte";
let htmlCanvas: HTMLCanvasElement;
let fabricCanvas: fabric.Canvas;
@@ -149,59 +148,12 @@
}
};
const onSaveClicked = () => {
if (confirm($tr("editor.warning.save"))) {
try {
LocalStoragePersistence.saveLabel(labelProps, fabricCanvas.toJSON());
} catch (e) {
Toasts.zodErrors(e, "Canvas save error:");
}
}
const exportCurrentLabel = (): ExportedLabelTemplate => {
return FileUtils.makeExportedLabel(fabricCanvas, labelProps);
};
const onExportClicked = () => {
try {
FileUtils.saveLabelAsJson(fabricCanvas, labelProps);
} catch (e) {
Toasts.zodErrors(e, "Canvas save error:");
}
};
const onImportClicked = async () => {
const contents = await FileUtils.pickAndReadTextFile("json");
const rawData = JSON.parse(contents);
if (!confirm($tr("editor.warning.load"))) {
return;
}
try {
const data = ExportedLabelTemplateSchema.parse(rawData);
await loadLabelData(data);
undo.push(fabricCanvas, labelProps);
} catch (e) {
Toasts.zodErrors(e, "Canvas load error:");
}
};
const onLoadClicked = async () => {
if (!confirm($tr("editor.warning.load"))) {
return;
}
try {
const labelData = LocalStoragePersistence.loadLabel();
if (labelData === null) {
Toasts.error("No saved label data found, or data is corrupt");
return;
}
await loadLabelData(labelData);
undo.push(fabricCanvas, labelProps);
} catch (e) {
Toasts.zodErrors(e, "Canvas load error:");
}
const onLoadRequested = (label: ExportedLabelTemplate) => {
loadLabelData(label).then(() => undo.push(fabricCanvas, labelProps));
};
const zplImageReady = (img: Blob) => {
@@ -393,6 +345,8 @@
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
<LabelPropsEditor {labelProps} onChange={onUpdateLabelProps} />
<SavedLabelsMenu onRequestCurrentCanvas={exportCurrentLabel} {onLoadRequested} />
<button
class="btn btn-sm btn-secondary"
disabled={undoState.undoDisabled}
@@ -415,45 +369,8 @@
onUpdate={onCsvUpdate}
onPlaceholderPicked={onCsvPlaceholderPicked} />
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-secondary dropdown-toggle px-1" data-bs-toggle="dropdown">
<MdIcon icon="save" />
</button>
<div class="dropdown-menu px-2">
<div class="d-flex gap-1 flex-wrap">
<button class="btn btn-secondary btn-sm" on:click={onSaveClicked}>
<MdIcon icon="open_in_browser" />
{$tr("editor.save.browser")}
</button>
<button class="btn btn-secondary btn-sm" on:click={onExportClicked}>
<MdIcon icon="data_object" />
{$tr("editor.save.json")}
</button>
</div>
</div>
</div>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-secondary dropdown-toggle px-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="folder" />
</button>
<div class="dropdown-menu px-2">
<div class="d-flex gap-1 flex-wrap">
<button class="btn btn-secondary btn-sm" on:click={onLoadClicked}>
<MdIcon icon="open_in_browser" />
{$tr("editor.load.browser")}
</button>
<button class="btn btn-secondary btn-sm" on:click={onImportClicked}>
<MdIcon icon="data_object" />
{$tr("editor.load.json")}
</button>
<ZplImportButton {labelProps} onImageReady={zplImageReady} text={$tr("editor.import.zpl")} />
</div>
</div>
</div>
<IconPicker onSubmit={onIconPicked} />
<ObjectPicker onSubmit={onObjectPicked} />
<ObjectPicker onSubmit={onObjectPicked} labelProps={labelProps} zplImageReady={zplImageReady} />
<button class="btn btn-sm btn-primary ms-1" on:click={openPreview}>
<MdIcon icon="visibility" />

View File

@@ -33,7 +33,7 @@
};
</script>
<div class="preset-browser overflow-y-auto border d-flex gap-1 flex-wrap {$$props.class}">
<div class="preset-browser overflow-y-auto border d-flex p-2 gap-1 flex-wrap {$$props.class}">
{#each presets as item, idx}
<button
class="btn p-0 card-wrapper d-flex justify-content-center align-items-center"

View File

@@ -167,9 +167,9 @@
<MdIcon icon="settings" />
</button>
<div class="dropdown-menu">
<h6 class="dropdown-header">{$tr("params.label.dialog_title")}</h6>
<h6 class="dropdown-header">{$tr("params.label.menu_title")}</h6>
<div class="p-3">
<div class="px-3">
<div class="mb-3 {error ? 'cursor-help text-warning' : 'text-secondary'}" title={error}>
{$tr("params.label.current")}
{labelProps.size.width}x{labelProps.size.height}
@@ -186,8 +186,7 @@
class="mb-1"
presets={labelPresets}
onItemSelected={onLabelPresetSelected}
onItemDelete={onLabelPresetDelete}
onItemAdd={onLabelPresetAdd} />
onItemDelete={onLabelPresetDelete} />
<div class="input-group flex-nowrap input-group-sm mb-3">
<span class="input-group-text">{$tr("params.label.size")}</span>

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import { type OjectType } from "../types";
import { type LabelProps, type OjectType } from "../types";
import { tr } from "../utils/i18n";
import MdIcon from "./MdIcon.svelte";
import ZplImportButton from "./ZplImportButton.svelte";
export let onSubmit: (i: OjectType) => void;
export let labelProps: LabelProps;
export let zplImageReady: (img: Blob) => void;
</script>
<div class="dropdown">
@@ -44,6 +47,8 @@
<MdIcon icon="view_week" />
{$tr("editor.objectpicker.barcode")}
</button>
<ZplImportButton {labelProps} onImageReady={zplImageReady} text={$tr("editor.import.zpl")} />
</div>
</div>
</div>

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import type { ExportedLabelTemplate, LabelProps } from "../types";
import { tr } from "../utils/i18n";
import MdIcon from "./MdIcon.svelte";
export let onItemClicked: (index: number) => void;
export let onItemDelete: (index: number) => void;
export let onItemExport: (index: number) => void;
export let labels: ExportedLabelTemplate[];
export let selectedIndex: number = -1;
let deleteIndex: 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 {$$props.class}">
{#each labels as item, idx}
<button
class="btn p-0 card-wrapper d-flex justify-content-center align-items-center {selectedIndex===idx ? 'border-primary' : ''}"
on:click={() => onItemClicked(idx)}>
<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" on:click={(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" on:click={(e) => deleteConfirmed(e, idx)}>
<MdIcon icon="delete" />
</button>
<button class="remove btn text-success" on:click={(e) => deleteRejected(e)}>
<MdIcon icon="close" />
</button>
{:else}
<button class="remove btn text-danger-emphasis" on:click={(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>
</button>
{/each}
</div>
<style>
.labels-browser {
max-height: 200px;
max-width: 100%;
min-height: 96px;
}
.card-wrapper {
width: 96px;
height: 96px;
}
.card {
background-color: white;
position: relative;
}
.card > .buttons {
position: absolute;
top: 0;
right: 0;
z-index: 2;
}
.card > .buttons > button {
padding: 0;
line-height: 100%;
}
.card > .label {
background-color: rgba(255, 255, 255, 0.8);
color: black;
border-radius: 8px;
z-index: 1;
}
.card.print-start-left {
border-left: 2px solid #ff4646;
}
.card.print-start-top {
border-top: 2px solid #ff4646;
}
.card .thumbnail {
width: 100%;
height: 100%;
position: absolute;
}
</style>

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import { tr } from "../utils/i18n";
import { onMount } from "svelte";
import MdIcon from "./MdIcon.svelte";
import SavedLabelsBrowser from "./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";
export let onRequestCurrentCanvas: () => ExportedLabelTemplate;
export let onLoadRequested: (label: ExportedLabelTemplate) => void;
let dropdownRef: Element;
let savedLabels: ExportedLabelTemplate[] = [];
let selectedIndex: number = -1;
let title: string = "";
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 = "";
};
const onSaveReplaceClicked = () => {
if (selectedIndex === -1) {
return;
}
if (!confirm($tr("editor.warning.save"))) {
return;
}
const label = onRequestCurrentCanvas();
label.title = title;
const result = [...savedLabels];
result[selectedIndex] = label;
const errors = LocalStoragePersistence.saveLabels(result);
errors.forEach((e) => Toasts.zodErrors(e, "Label save error"));
if (errors.length === 0) {
savedLabels = result;
}
};
const onSaveClicked = () => {
const label = onRequestCurrentCanvas();
label.title = title;
const result = [...savedLabels, label];
console.log(result);
const errors = LocalStoragePersistence.saveLabels(result);
errors.forEach((e) => Toasts.zodErrors(e, "Label save error"));
if (errors.length === 0) {
savedLabels = result;
}
};
const onLoadClicked = () => {
if (selectedIndex === -1) {
return;
}
if (!confirm($tr("editor.warning.load"))) {
return;
}
onLoadRequested(savedLabels[selectedIndex]);
new Dropdown(dropdownRef).hide();
};
const onImportClicked = async () => {
const contents = await FileUtils.pickAndReadTextFile("json");
const rawData = JSON.parse(contents);
if (!confirm($tr("editor.warning.load"))) {
return;
}
try {
onLoadRequested(ExportedLabelTemplateSchema.parse(rawData));
new Dropdown(dropdownRef).hide();
} catch (e) {
Toasts.zodErrors(e, "Canvas load error:");
}
};
const onExportClicked = () => {
try {
FileUtils.saveLabelAsJson(onRequestCurrentCanvas());
} catch (e) {
Toasts.zodErrors(e, "Canvas save error:");
}
};
const reload = () => {
savedLabels = LocalStoragePersistence.loadLabels();
};
onMount(() => {
reload();
});
</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="dropdown-menu" bind:this={dropdownRef}>
<h6 class="dropdown-header">{$tr("params.saved_labels.menu_title")}</h6>
<div class="px-3">
<div class="p-1">
<button class="btn btn-sm btn-outline-secondary" on:click={onImportClicked}>
<MdIcon icon="data_object" />
{$tr("params.saved_labels.load.json")}
</button>
<button class="btn btn-sm btn-outline-secondary" on:click={onExportClicked}>
<MdIcon icon="data_object" />
{$tr("params.saved_labels.save.json")}
</button>
</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">
<button class="btn btn-sm btn-secondary" on:click={onSaveClicked}>
<MdIcon icon="save" />
{$tr("params.saved_labels.save.browser")}
</button>
{#if selectedIndex !== -1}
<button class="btn btn-sm btn-secondary" on:click={onSaveReplaceClicked}>
<MdIcon icon="edit_note" />
{$tr("params.saved_labels.save.browser.replace")}
</button>
<button class="btn btn-sm btn-primary" on:click={onLoadClicked}>
<MdIcon icon="folder" />
{$tr("params.saved_labels.load.browser")}
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.dropdown-menu {
min-width: 450px;
}
</style>

View File

@@ -45,7 +45,7 @@
};
</script>
<button class="btn btn-secondary btn-sm" on:click={onImportClicked}>
<button class="btn btn-sm" on:click={onImportClicked}>
<MdIcon icon="receipt_long" />
{text}
{#if state === "processing"}

View File

@@ -10,14 +10,12 @@ export const translation_en = {
"editor.redo": "Redo",
"editor.default_text": "Text",
"editor.delete": "Delete",
"editor.save.json": "Save (JSON)",
"editor.save.browser": "Save (browser)",
"editor.load.json": "Load (JSON)",
"editor.load.browser": "Load (browser)",
"editor.import.zpl": "Import ZPL",
"editor.iconpicker.search": "Search",
"editor.iconpicker.title": "Add icon",
"editor.iconpicker.mdi_link_title": "See detailed list here",
"editor.objectpicker.barcode": "Barcode",
"editor.objectpicker.circle": "Circle",
"editor.objectpicker.image": "Image",
@@ -26,27 +24,33 @@ export const translation_en = {
"editor.objectpicker.rectangle": "Rectangle",
"editor.objectpicker.text": "Text",
"editor.objectpicker.title": "Add object",
"editor.preview": "Preview",
"editor.print": "Print",
"editor.warning.load": "Canvas will be replaced with saved data",
"editor.warning.save": "Saved data will be overwritten. Save?",
"main.built": "built at",
"main.code": "Code",
"params.barcode.content": "Content",
"params.barcode.enable_caption": "Enable caption",
"params.barcode.encoding": "Encoding",
"params.barcode.font_size": "Font size",
"params.barcode.scale": "Scale factor",
"params.csv.enabled": "Enabled",
"params.csv.placeholders": "Variables:",
"params.csv.rowsfound": "Data rows found:",
"params.csv.tip": "First row is a header. It used as variable names. Commas are used as separators.",
"params.csv.title": "Dynamic label data (CSV)",
"params.generic.center.horizontal": "Center horizontally",
"params.generic.center.vertical": "Center vertically",
"params.label.apply": "Apply",
"params.label.current": "Current parameters:",
"params.label.dialog_title": "Label properties",
"params.label.menu_title": "Label properties",
"params.label.direction.left": "Left",
"params.label.direction.top": "Top",
"params.label.direction": "Print from",
@@ -60,7 +64,9 @@ export const translation_en = {
"params.label.size": "Size",
"params.label.warning.direction": "Recommended direction for your printer:",
"params.label.warning.width": "Label width is too big for your printer:",
"params.qrcode.ecl": "Error Correction Level",
"params.text.align.center": "Align text: Center",
"params.text.align.left": "Align text: Left",
"params.text.align.right": "Align text: Right",
@@ -78,10 +84,21 @@ export const translation_en = {
"params.text.vorigin": "Vertical Origin",
"params.text.edit": "Edit in popup",
"params.text.edit.title": "Editing text",
"params.variables.insert.date": "Date",
"params.variables.insert.datetime": "Datetime",
"params.variables.insert.time": "Time",
"params.variables.insert": "Insert variable",
"params.saved_labels.menu_title": "Save/load (browser storage)",
"params.saved_labels.save.json": "Export JSON",
"params.saved_labels.save.browser": "Save",
"params.saved_labels.save.browser.replace": "Save (replace)",
"params.saved_labels.load.json": "Import JSON",
"params.saved_labels.load.browser": "Load",
"params.saved_labels.label_title": "Title",
"params.saved_labels.label_title.placeholder": "(optional)",
"preview.close": "Close",
"preview.copies": "Copies",
"preview.density": "Density",

View File

@@ -14,10 +14,6 @@ export const translation_ru: Record<TranslationKey, string> = {
"connector.disconnect.heartbeat": "Отключено (принтер не отвечает)",
/* ImageEditor */
"editor.default_text": "Текст",
"editor.save.json": "Сохранить (JSON)",
"editor.save.browser": "Сохранить (браузер)",
"editor.load.json": "Загрузить (JSON)",
"editor.load.browser": "Загрузить (браузер)",
"editor.import.zpl": "Импорт ZPL",
"editor.preview": "Предпросмотр",
"editor.print": "Печать",
@@ -71,7 +67,7 @@ export const translation_ru: Record<TranslationKey, string> = {
"params.csv.enabled": "Включить",
"params.csv.placeholders": "Переменные:",
/* LabelPropsEditor */
"params.label.dialog_title": "Настройки этикетки",
"params.label.menu_title": "Настройки этикетки",
"params.label.label_title": "Своё название",
"params.label.size": "Размер",
"params.label.mm": "мм",
@@ -114,10 +110,19 @@ export const translation_ru: Record<TranslationKey, string> = {
"params.generic.center.horizontal": "Выровнять горизонтально",
/* QRCodeParamsControls */
"params.qrcode.ecl": "Уровень коррекции ошибок",
/** BarcodeParamsControls */
/* BarcodeParamsControls */
"params.barcode.encoding": "Тип",
"params.barcode.content": "Содержимое",
"params.barcode.scale": "Масштаб",
"params.barcode.font_size": "Размер шрифта",
"params.barcode.enable_caption": "Показывать надпись",
/* SavedLabelsMenu */
"params.saved_labels.menu_title": "Сохранить/загрузить (хранилище браузера)",
"params.saved_labels.save.json": "Экспорт JSON",
"params.saved_labels.save.browser": "Сохранить",
"params.saved_labels.save.browser.replace": "Сохранить (заменить)",
"params.saved_labels.load.json": "Импорт JSON",
"params.saved_labels.load.browser": "Открыть",
"params.saved_labels.label_title": "Название",
"params.saved_labels.label_title.placeholder": "(необязательно)",
};

View File

@@ -18,8 +18,7 @@ export const translation_zh_cn: Partial<Record<TranslationKey, string>> = {
"editor.iconpicker.search": "搜索",
"editor.iconpicker.title": "添加图标",
"editor.import.zpl": "导入 ZPL 文件",
"editor.load.browser": "从浏览器导入",
"editor.load.json": "导入 JSON 文件",
"editor.objectpicker.barcode": "条码",
"editor.objectpicker.circle": "圆形",
"editor.objectpicker.image": "图片",
@@ -30,8 +29,6 @@ export const translation_zh_cn: Partial<Record<TranslationKey, string>> = {
"editor.objectpicker.title": "添加元素",
"editor.preview": "预览",
"editor.print": "打印",
"editor.save.browser": "保存到浏览器",
"editor.save.json": "导出 JSON 文件",
"editor.warning.load": "画布将被替换为保存的数据,需要继续吗?",
"editor.warning.save": "保存的数据将会被覆盖,需要继续吗?",
/* Main page */
@@ -55,7 +52,7 @@ export const translation_zh_cn: Partial<Record<TranslationKey, string>> = {
/* LabelPropsEditor */
"params.label.apply": "应用",
"params.label.current": "当前设置:",
"params.label.dialog_title": "标签设置",
"params.label.menu_title": "标签设置",
"params.label.direction.left": "向左",
"params.label.direction.top": "向上",
"params.label.direction": "出纸方向",
@@ -92,6 +89,11 @@ export const translation_zh_cn: Partial<Record<TranslationKey, string>> = {
"params.variables.insert.datetime": "日期时间",
"params.variables.insert.time": "时间",
"params.variables.insert": "插入变量",
/* SavedLabelsMenu */
"params.saved_labels.load.browser": "从浏览器导入",
"params.saved_labels.load.json": "导入 JSON 文件",
"params.saved_labels.save.browser": "保存到浏览器",
"params.saved_labels.save.json": "导出 JSON 文件",
/* PrintPreview */
"preview.close": "关闭",
"preview.copies": "份数",

View File

@@ -39,6 +39,9 @@ export const FabricJsonSchema = z.object({
export const ExportedLabelTemplateSchema = z.object({
canvas: FabricJsonSchema,
label: LabelPropsSchema,
thumbnailBase64: z.string().optional(),
title: z.string().optional(),
timestamp: z.number().positive().optional()
});
const [firstTask, ...otherTasks] = printTaskNames;

View File

@@ -1,19 +1,36 @@
import type { fabric } from "fabric";
import { ExportedLabelTemplateSchema, type ExportedLabelTemplate, type FabricJson, type LabelProps } from "../types";
import { OBJECT_DEFAULTS } from "../defaults";
import { OBJECT_DEFAULTS, THUMBNAIL_HEIGHT, THUMBNAIL_QUALITY } from "../defaults";
export class FileUtils {
/** Convert label template to JSON and download it */
static saveLabelAsJson(canvas: fabric.Canvas, labelProps: LabelProps) {
const timestamp = Math.floor(Date.now() / 1000);
static timestamp(): number {
return Math.floor(Date.now() / 1000);
}
const labelRaw: ExportedLabelTemplate = {
static makeExportedLabel (canvas: fabric.Canvas, labelProps: LabelProps): ExportedLabelTemplate {
const thumbnailBase64: string = canvas.toDataURL({
width: canvas.width,
height: canvas.height,
left: 0,
top: 0,
multiplier: THUMBNAIL_HEIGHT / (canvas.height || 1),
quality: THUMBNAIL_QUALITY,
format: "jpeg",
});
return {
canvas: canvas.toJSON(),
label: labelProps,
thumbnailBase64,
timestamp: this.timestamp()
};
};
const label = ExportedLabelTemplateSchema.parse(labelRaw);
const json: string = JSON.stringify(label);
/** Convert label template to JSON and download it */
static saveLabelAsJson(label: ExportedLabelTemplate) {
const parsed = ExportedLabelTemplateSchema.parse(label);
const timestamp = label.timestamp ?? this.timestamp();
const json: string = JSON.stringify(parsed);
const link = document.createElement("a");
const file: Blob = new Blob([json], { type: "text/json" });
link.href = URL.createObjectURL(file);

View File

@@ -6,12 +6,12 @@ import {
PreviewPropsSchema,
type ConnectionType,
type ExportedLabelTemplate,
type FabricJson,
type LabelPreset,
type LabelProps,
type PreviewProps,
} from "../types";
import { z } from "zod";
import { FileUtils } from "./file_utils";
export class LocalStoragePersistence {
static saveObject(key: string, data: any) {
@@ -88,29 +88,71 @@ export class LocalStoragePersistence {
this.validateAndSaveObject("last_label_props", labelData, LabelPropsSchema);
}
/**
* @throws {z.ZodError}
*/
static saveLabel(labelData: LabelProps, canvasData: FabricJson) {
const obj = { label: labelData, canvas: canvasData };
this.validateAndSaveObject("saved_label", obj, ExportedLabelTemplateSchema);
static saveLabels(labels: ExportedLabelTemplate[]): z.ZodError[] {
const errors: z.ZodError[] = [];
Object.keys(localStorage).forEach((key) => {
if (key.startsWith("saved_label")) {
localStorage.removeItem(key);
}
});
labels.forEach((label) => {
try {
if (label.timestamp == undefined) {
label.timestamp = FileUtils.timestamp();
}
const basename = `saved_label_${label.timestamp}`;
let counter = 0;
while (`${basename}_${counter}` in localStorage) {
counter++;
}
this.validateAndSaveObject(`${basename}_${counter}`, label, ExportedLabelTemplateSchema);
} catch (e) {
console.error(e);
if (e instanceof z.ZodError) {
errors.push(e);
}
}
});
return errors;
}
/**
* @throws {z.ZodError}
*/
static loadLabel(): ExportedLabelTemplate | null {
const label = this.loadAndValidateObject("saved_canvas_props", LabelPropsSchema);
const canvas = this.loadAndValidateObject("saved_canvas_data", FabricJsonSchema);
static loadLabels(): ExportedLabelTemplate[] {
const legacyLabel = this.loadAndValidateObject("saved_canvas_props", LabelPropsSchema);
const legacyCanvas = this.loadAndValidateObject("saved_canvas_data", FabricJsonSchema);
const items: ExportedLabelTemplate[] = [];
if (label !== null && canvas !== null) {
this.saveLabel(label, canvas);
if (legacyLabel !== null && legacyCanvas !== null) {
localStorage.removeItem("saved_canvas_props");
localStorage.removeItem("saved_canvas_data");
return { label, canvas };
const item: ExportedLabelTemplate = {
label: legacyLabel,
canvas: legacyCanvas,
timestamp: FileUtils.timestamp(),
};
this.validateAndSaveObject(`saved_label_${item.timestamp}`, item, ExportedLabelTemplateSchema);
}
return this.loadAndValidateObject("saved_label", ExportedLabelTemplateSchema);
Object.keys(localStorage).sort().forEach((key) => {
if (key.startsWith("saved_label")) {
try {
const item = this.loadAndValidateObject(key, ExportedLabelTemplateSchema);
if (item != null) {
items.push(item);
}
} catch (e) {
console.error(e);
}
}
});
return items;
}
/**