Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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