Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ File menu → **Import ZPL**: paste ZPL code directly, or open a `.zpl` file.

File menu → **Add page** creates a new page. With multiple pages, the control at the bottom-center of the canvas switches between them and removes them. All pages share the same dimensions; export and import handle each page as a separate label.

### Batch printing from CSV

File menu → **Import CSV data** loads a CSV. The mapping dialog pairs each Variable with a column, saved with the design. **Export batch ZPL** or **Send to Zebra Printer** then outputs one label per row.

### Keyboard shortcuts

| Shortcut | Action |
Expand Down Expand Up @@ -99,6 +103,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte
- Smart alignment and spacing guides
- Layers panel with reordering
- Variables: bind text and barcode fields to named defaults that emit as `^FN`/`^FV` slots, round-tripping with printer-side templates
- CSV batch printing: import a CSV, map columns to Variables, print or export one label per row
- 32 UI languages (auto-detected from browser)
- Light / dark mode (follows OS setting)

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"bwip-js": "^4.10.1",
"fflate": "^0.8.3",
"konva": "^10.3.0",
"papaparse": "^5.5.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-konva": "^19.2.4",
Expand All @@ -34,6 +35,7 @@
"@tailwindcss/vite": "^4.3.0",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.12.4",
"@types/papaparse": "^5.5.2",
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.5",
"@types/react": "^19.2.15",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 53 additions & 3 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { LabelCanvasHandle } from "./Canvas/LabelCanvas";
import { RightSidebar } from "./RightSidebar/RightSidebar";
import { ZPLOutput } from "./Output/ZPLOutput";
import { ZplImportModal } from "./Output/ZplImportModal";
import { VariableMappingModal } from "./Variables/VariableMappingModal";
import { CsvImportConfirmDialog } from "./Variables/CsvImportConfirmDialog";
import { PrintToZebraDialog } from "./Output/PrintToZebraDialog";
import {
DropdownMenu,
Expand All @@ -22,6 +24,7 @@ import {
DocumentDuplicateIcon,
FolderOpenIcon,
DocumentArrowDownIcon,
TableCellsIcon,
PrinterIcon,
PaperAirplaneIcon,
GlobeAltIcon,
Expand All @@ -38,6 +41,7 @@ import { useT } from "../lib/useT";
import { kbd } from "../lib/kbd";
import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts";
import { useDesignFileActions } from "../hooks/useDesignFileActions";
import { useCsvImportActions } from "../hooks/useCsvImportActions";
import { useZplImportExport } from "../hooks/useZplImportExport";
import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel";

Expand Down Expand Up @@ -75,6 +79,17 @@ export function AppShell() {

useGlobalShortcuts();
const { handleNew, handleSave, handleLoad, loadInputRef, loadError, dismissLoadError } = useDesignFileActions();
const {
csvInputRef,
handleCsvImport,
csvError,
dismissCsvError,
pendingImport,
confirmPendingImport,
cancelPendingImport,
} = useCsvImportActions();
const csvMappingModalOpen = useLabelStore((s) => s.csvMappingModalOpen);
const closeCsvMappingModal = useLabelStore((s) => s.closeCsvMappingModal);
const {
showZplImport,
openZplImport,
Expand All @@ -86,6 +101,9 @@ export function AppShell() {
printError,
dismissPrintError,
handleDownload,
handleExportBatch,
canBatchExport,
batchRowCount,
handlePrint,
} = useZplImportExport();
const outputPanel = useOutputPanel(OUTPUT_DEFAULT_H);
Expand Down Expand Up @@ -194,6 +212,15 @@ export function AppShell() {
>
{t.app.exportZpl}
</DropdownItem>
{canBatchExport && (
<DropdownItem
icon={ArrowDownTrayIcon}
onClick={handleExportBatch}
disabled={!hasObjects}
>
{t.app.exportBatchZplFmt.replace('{n}', String(batchRowCount))}
</DropdownItem>
)}
<DropdownSeparator />
<DropdownItem
icon={FolderOpenIcon}
Expand All @@ -208,6 +235,12 @@ export function AppShell() {
>
{t.app.saveDesign}
</DropdownItem>
<DropdownItem
icon={TableCellsIcon}
onClick={() => csvInputRef.current?.click()}
>
{t.app.importCsvData}
</DropdownItem>
<DropdownSeparator />
{/* Print routes through Labelary. The button is shown whenever
the Labelary gate is on; clicking it before the notice has
Expand Down Expand Up @@ -237,13 +270,20 @@ export function AppShell() {
className="hidden"
onChange={handleLoad}
/>
<input
ref={csvInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleCsvImport}
/>
</div>
</header>

{/* Notices */}
{(loadError ?? printError) && (
{(loadError ?? printError ?? csvError) && (
<div className="shrink-0 flex items-center gap-3 px-4 py-2 bg-red-950/40 border-b border-red-800/50 font-mono text-[10px] text-red-300">
<span className="flex-1">{loadError ?? printError}</span>
<span className="flex-1">{loadError ?? printError ?? csvError}</span>
{printError && (
<button
onClick={handleDownload}
Expand All @@ -254,7 +294,7 @@ export function AppShell() {
</button>
)}
<button
onClick={loadError ? dismissLoadError : dismissPrintError}
onClick={loadError ? dismissLoadError : csvError ? dismissCsvError : dismissPrintError}
className="text-red-400 hover:text-red-200 transition-colors"
aria-label="Dismiss"
>
Expand Down Expand Up @@ -310,6 +350,16 @@ export function AppShell() {
</div>

{showZplImport && <ZplImportModal onClose={closeZplImport} />}
{csvMappingModalOpen && (
<VariableMappingModal onClose={closeCsvMappingModal} />
)}
{pendingImport && (
<CsvImportConfirmDialog
pending={pendingImport}
onConfirm={confirmPendingImport}
onCancel={cancelPendingImport}
/>
)}
{showZebraPrint && (
<PrintToZebraDialog zpl={currentZpl()} onClose={closeZebraPrint} />
)}
Expand Down
73 changes: 64 additions & 9 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useMemo } from "react";
import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
import { Ellipse, Group, Rect, Text } from "react-konva";
import { lookupBoundVariable, shouldShowFallbackTint } from "../../lib/variableBinding";
import { BarcodeObject } from "./BarcodeObject";
import { LineObject } from "./LineObject";
import { ImageObject } from "./ImageObject";
Expand All @@ -9,7 +11,7 @@ import { outlineInset } from "../../lib/shapeGeometry";
import { reverseShapeStyle } from "./reverseShapeStyle";
import { useColorScheme } from "../../lib/useColorScheme";
import { useLabelStore } from "../../store/labelStore";
import { applyBindingToObject } from "../../lib/variableBinding";
import { applyBindingToObject, buildActiveCsvRow } from "../../lib/variableBinding";
import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "./textPositionTransforms";
import { getTextRenderMetrics } from "./textRenderMetrics";
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
Expand Down Expand Up @@ -106,19 +108,72 @@ const BARCODE_TYPES = new Set([
]);

export function KonvaObject(props_: Props) {
// Substitute the bound variable's defaultValue into `props.content`
// Substitute the bound variable's resolved value into `props.content`
// before any per-type renderer touches the obj. Keeps Konva blissfully
// unaware of the binding mechanism: every shape draws what the printer
// would print absent a runtime ^FV override. `applyBindingToObject`
// unaware of the binding mechanism: every shape draws what would
// print for the currently active CSV row (or the variable's
// defaultValue when no CSV is in play). `applyBindingToObject`
// is identity-preserving when the obj isn't bound, so memoisation
// downstream isn't affected for the common case.
const variables = useLabelStore((s) => s.variables);
const obj = applyBindingToObject(props_.obj, variables);
const csvDataset = useLabelStore((s) => s.csvDataset);
const csvMapping = useLabelStore((s) => s.csvMapping);
const csvRenderMode = useLabelStore((s) => s.canvasSettings.csvRenderMode);
// useMemo so applyBindingToObject's identity-preservation downstream
// isn't defeated by a fresh ActiveCsvRow object on every render.
const active = useMemo(
() => buildActiveCsvRow(csvDataset, csvMapping),
[csvDataset, csvMapping],
);
const obj = applyBindingToObject(props_.obj, variables, active, csvRenderMode);
const renderProps = obj === props_.obj ? props_ : { ...props_, obj };
if (obj.type === "line") return <LineObject {...renderProps} obj={obj} />;
if (obj.type === "image") return <ImageObject {...renderProps} obj={obj} />;
if (BARCODE_TYPES.has(obj.type)) return <BarcodeObject {...renderProps} />;
return <KonvaObjectInner {...renderProps} />;

// Bounds-tint when bound field is rendering fallback (no CSV cell
// for this variable). The rule lives in lib so it's testable
// without Konva; see shouldShowFallbackTint for the full predicate.
const boundVariable = lookupBoundVariable(obj, variables);
const showFallbackTint = shouldShowFallbackTint(
boundVariable, csvDataset, csvMapping, csvRenderMode,
);

const shape =
obj.type === "line" ? <LineObject {...renderProps} obj={obj} /> :
obj.type === "image" ? <ImageObject {...renderProps} obj={obj} /> :
BARCODE_TYPES.has(obj.type) ? <BarcodeObject {...renderProps} /> :
<KonvaObjectInner {...renderProps} />;

if (!showFallbackTint) return shape;

// Read declared bbox from props. Shapes with explicit width/height
// (text, box, ellipse, image, qrcode, datamatrix) produce a visible
// tint; barcodes whose width is derived from content length (Code39,
// Code128, EAN-13, …) silently skip — their bars are visually
// distinctive enough that fallback ambiguity is less acute, and the
// source-state badge in the Variables panel covers them.
const props = (obj as { props?: { width?: unknown; height?: unknown } }).props;
const wDots = typeof props?.width === "number" ? props.width : 0;
const hDots = typeof props?.height === "number" ? props.height : 0;
if (wDots <= 0 || hDots <= 0) return shape;
const { scale, dpmm, offsetX, offsetY } = props_;
const tintX = dotsToPx(obj.x, scale, dpmm) - offsetX;
const tintY = dotsToPx(obj.y, scale, dpmm) - offsetY;
const tintW = dotsToPx(wDots, scale, dpmm);
const tintH = dotsToPx(hDots, scale, dpmm);

return (
<Group>
<Rect
x={tintX}
y={tintY}
width={tintW}
height={tintH}
fill="#fb923c"
opacity={0.12}
listening={false}
/>
{shape}
</Group>
);
}

/**
Expand Down
Loading