Port NiimBlue label designer to Fichero D11s with local BLE protocol library
This commit is contained in:
287
web/src/fabric-object/barcode.ts
Normal file
287
web/src/fabric-object/barcode.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import * as fabric from "fabric";
|
||||
import { code128b, ean13 } from "$/utils/barcode";
|
||||
import { CanvasUtils } from "$/utils/canvas_utils";
|
||||
import { OBJECT_DEFAULTS_TEXT } from "$/defaults";
|
||||
|
||||
const EAN13_LONG_BAR_INDEXES: number[] = [
|
||||
0, 1, 2, 45, 46, 47, 48, 49, 92, 93, 94,
|
||||
];
|
||||
export type BarcodeCoding = "EAN13" | "CODE128B";
|
||||
|
||||
export const barcodeDefaultValues: Partial<fabric.TClassProperties<Barcode>> = {
|
||||
text: "",
|
||||
encoding: "EAN13",
|
||||
printText: true,
|
||||
scaleFactor: 1,
|
||||
fontSize: 12,
|
||||
fontFamily: OBJECT_DEFAULTS_TEXT.fontFamily,
|
||||
};
|
||||
|
||||
interface UniqueBarcodeProps {
|
||||
text: string;
|
||||
encoding: BarcodeCoding;
|
||||
printText: boolean;
|
||||
scaleFactor: number;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}
|
||||
export interface BarcodeProps
|
||||
extends fabric.FabricObjectProps,
|
||||
UniqueBarcodeProps {}
|
||||
export interface SerializedBarcodeProps
|
||||
extends fabric.SerializedObjectProps,
|
||||
UniqueBarcodeProps {}
|
||||
const BARCODE_PROPS = [
|
||||
"text",
|
||||
"encoding",
|
||||
"printText",
|
||||
"scaleFactor",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
] as const;
|
||||
|
||||
export class Barcode<
|
||||
Props extends fabric.TOptions<BarcodeProps> = Partial<BarcodeProps>,
|
||||
SProps extends SerializedBarcodeProps = SerializedBarcodeProps,
|
||||
EventSpec extends fabric.ObjectEvents = fabric.ObjectEvents,
|
||||
>
|
||||
extends fabric.FabricObject<Props, SProps, EventSpec>
|
||||
implements BarcodeProps
|
||||
{
|
||||
static override type = "Barcode";
|
||||
|
||||
/**
|
||||
* Barcode text
|
||||
* @type string
|
||||
* @default ""
|
||||
*/
|
||||
declare text: string;
|
||||
/**
|
||||
* Barcode encoding
|
||||
* @type BarcodeCoding
|
||||
* @default "EAN13"
|
||||
*/
|
||||
declare encoding: BarcodeCoding;
|
||||
/**
|
||||
* Print text
|
||||
* @type boolean
|
||||
* @default true
|
||||
*/
|
||||
declare printText: boolean;
|
||||
/**
|
||||
* Scale factor
|
||||
* @type number
|
||||
* @default 1
|
||||
*/
|
||||
declare scaleFactor: number;
|
||||
/**
|
||||
* Font size
|
||||
* @type number
|
||||
* @default 12
|
||||
*/
|
||||
declare fontSize: number;
|
||||
/**
|
||||
* Font family
|
||||
* @type string
|
||||
* @default "Noto Sans Variable"
|
||||
*/
|
||||
declare fontFamily: string;
|
||||
|
||||
private barcodeEncoded: string = "";
|
||||
private displayText: string = "";
|
||||
|
||||
constructor(options?: Props) {
|
||||
super();
|
||||
Object.assign(this, barcodeDefaultValues);
|
||||
const { text, ...other } = options ?? {};
|
||||
this.setOptions(other); // Must be set separately because the encoding needs to be set first
|
||||
this.set("text", text);
|
||||
this.setControlsVisibility({
|
||||
tl: false,
|
||||
tr: false,
|
||||
bl: false,
|
||||
br: false,
|
||||
ml: false,
|
||||
mr: false,
|
||||
mtr: false,
|
||||
});
|
||||
this.objectCaching = false;
|
||||
this._createBandCode();
|
||||
}
|
||||
|
||||
override _set(key: string, value?: any): this {
|
||||
super._set(key, value);
|
||||
|
||||
if (key === "text" || key == "encoding") {
|
||||
this._createBandCode();
|
||||
}
|
||||
|
||||
if (
|
||||
this.barcodeEncoded &&
|
||||
(BARCODE_PROPS.includes(key as any) || key == "canvas")
|
||||
) {
|
||||
const letterWidth = this._measureLetterWidth();
|
||||
let barcodeWidth = (this.scaleFactor ?? 1) * this.barcodeEncoded.length;
|
||||
|
||||
if (this.encoding === "EAN13") {
|
||||
barcodeWidth += letterWidth * 2; // side margins
|
||||
}
|
||||
super.set("width", barcodeWidth);
|
||||
this.setCoords();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
_createBandCode() {
|
||||
if (this.encoding === "EAN13") {
|
||||
const { text, bandcode } = ean13(this.text);
|
||||
this.displayText = text;
|
||||
this.barcodeEncoded = bandcode;
|
||||
} else {
|
||||
this.displayText = this.text;
|
||||
this.barcodeEncoded = code128b(this.text);
|
||||
}
|
||||
}
|
||||
|
||||
_getFont(): string {
|
||||
return `bold ${this.fontSize}px ${this.fontFamily}`;
|
||||
}
|
||||
|
||||
// parent canvas is needed for this operation
|
||||
_measureLetterWidth(): number {
|
||||
const ctx = this.canvas?.getContext();
|
||||
let w = 0;
|
||||
|
||||
if (ctx !== undefined) {
|
||||
ctx.save();
|
||||
ctx.font = this._getFont();
|
||||
w = ctx.measureText("0").width;
|
||||
ctx.restore();
|
||||
}
|
||||
return Math.ceil(w);
|
||||
}
|
||||
|
||||
override _render(ctx: CanvasRenderingContext2D) {
|
||||
if (this.barcodeEncoded === "") {
|
||||
super._render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const letterWidth = this._measureLetterWidth();
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(-this.width / 2, -this.height / 2); // make top-left origin
|
||||
ctx.translate(0.5, 0.5); // blurry rendering fix
|
||||
|
||||
ctx.font = this._getFont();
|
||||
ctx.textBaseline = "bottom";
|
||||
|
||||
const longBarHeight = this.height;
|
||||
let shortBarHeight = this.height;
|
||||
const barcodeStartPos = this.encoding === "EAN13" ? letterWidth : 0;
|
||||
|
||||
if (this.printText) {
|
||||
shortBarHeight -= this.fontSize * 1.2;
|
||||
} else if (this.encoding === "EAN13") {
|
||||
shortBarHeight -= 8;
|
||||
}
|
||||
|
||||
let blackStartPosition = -1;
|
||||
let blackCount = 0;
|
||||
let isLongBar = false;
|
||||
|
||||
// render barcode
|
||||
for (let i = 0; i < this.barcodeEncoded.length; i++) {
|
||||
const isBlack = this.barcodeEncoded[i] === "1";
|
||||
const xPos = barcodeStartPos + i * this.scaleFactor;
|
||||
|
||||
if (isBlack) {
|
||||
blackCount++;
|
||||
|
||||
if (blackStartPosition == -1) {
|
||||
blackStartPosition = xPos;
|
||||
}
|
||||
|
||||
if (this.encoding === "EAN13" && EAN13_LONG_BAR_INDEXES.includes(i)) {
|
||||
isLongBar = true;
|
||||
}
|
||||
|
||||
if (blackStartPosition != -1 && i === this.barcodeEncoded.length - 1) {
|
||||
// last index
|
||||
ctx.fillRect(
|
||||
blackStartPosition,
|
||||
0,
|
||||
this.scaleFactor * blackCount,
|
||||
isLongBar ? longBarHeight : shortBarHeight,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ctx.fillRect(
|
||||
blackStartPosition,
|
||||
0,
|
||||
this.scaleFactor * blackCount,
|
||||
isLongBar ? longBarHeight : shortBarHeight,
|
||||
);
|
||||
blackStartPosition = -1;
|
||||
blackCount = 0;
|
||||
isLongBar = false;
|
||||
}
|
||||
}
|
||||
|
||||
// render text
|
||||
if (this.printText) {
|
||||
if (this.encoding === "EAN13") {
|
||||
const parts = [
|
||||
this.displayText[0],
|
||||
this.displayText.slice(1, 7),
|
||||
this.displayText.slice(7, 13),
|
||||
">",
|
||||
];
|
||||
const midPartWidth = 40;
|
||||
const longBars1End = 4;
|
||||
const longBars2End = 50;
|
||||
|
||||
ctx.fillText(parts[0], 0, this.height); // first digit
|
||||
|
||||
CanvasUtils.equalSpacingFillText(
|
||||
ctx,
|
||||
parts[1],
|
||||
letterWidth + longBars1End * this.scaleFactor,
|
||||
this.height,
|
||||
midPartWidth * this.scaleFactor,
|
||||
); // part 1
|
||||
|
||||
CanvasUtils.equalSpacingFillText(
|
||||
ctx,
|
||||
parts[2],
|
||||
letterWidth + longBars2End * this.scaleFactor,
|
||||
this.height,
|
||||
midPartWidth * this.scaleFactor,
|
||||
); // part 2
|
||||
|
||||
ctx.fillText(parts[3], this.width - letterWidth, this.height); // last digit
|
||||
} else {
|
||||
CanvasUtils.equalSpacingFillText(
|
||||
ctx,
|
||||
this.displayText,
|
||||
barcodeStartPos,
|
||||
this.height,
|
||||
this.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
super._render(ctx);
|
||||
}
|
||||
|
||||
override toObject(propertiesToInclude: any[] = []) {
|
||||
return super.toObject([...BARCODE_PROPS, ...propertiesToInclude]);
|
||||
}
|
||||
}
|
||||
|
||||
fabric.classRegistry.setClass(Barcode, "Barcode");
|
||||
|
||||
export default Barcode;
|
||||
458
web/src/fabric-object/custom_canvas.ts
Normal file
458
web/src/fabric-object/custom_canvas.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import * as fabric from "fabric";
|
||||
import { DEFAULT_LABEL_PROPS } from "$/defaults";
|
||||
import type { LabelProps } from "$/types";
|
||||
|
||||
type LabelBounds = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
type FoldSegment = { start: number; end: number };
|
||||
type FoldInfo = {
|
||||
axis: "vertical" | "horizontal" | "none";
|
||||
points: number[];
|
||||
segments: FoldSegment[];
|
||||
};
|
||||
type MirrorInfo = { pos: fabric.Point; flip: boolean };
|
||||
|
||||
export class CustomCanvas extends fabric.Canvas {
|
||||
private labelProps: LabelProps = DEFAULT_LABEL_PROPS;
|
||||
private readonly SEPARATOR_LINE_WIDTH = 2;
|
||||
private readonly ROUND_RADIUS = 10;
|
||||
private readonly TAIL_WIDTH = 40;
|
||||
private readonly GRAY = "#CFCFCF";
|
||||
private readonly MIRROR_GHOST_COLOR = "rgba(0, 0, 0, 0.3)";
|
||||
private customBackground: boolean = true;
|
||||
private highlightMirror: boolean = true;
|
||||
private virtualZoomRatio: number = 1;
|
||||
|
||||
constructor(
|
||||
el?: string | HTMLCanvasElement,
|
||||
options?: fabric.TOptions<fabric.CanvasOptions>,
|
||||
) {
|
||||
super(el, options);
|
||||
this.setupZoom();
|
||||
this.preserveObjectStacking = true;
|
||||
}
|
||||
|
||||
private setupZoom() {
|
||||
this.on("mouse:wheel", (opt) => {
|
||||
const event = opt.e as WheelEvent;
|
||||
event.preventDefault();
|
||||
|
||||
const delta = event.deltaY;
|
||||
if (delta > 0) {
|
||||
this.virtualZoomOut();
|
||||
} else {
|
||||
this.virtualZoomIn();
|
||||
}
|
||||
});
|
||||
this.on("mouse:down:before", (opt) => {
|
||||
const event = opt.e as MouseEvent;
|
||||
if (event.button == 1) {
|
||||
event.preventDefault();
|
||||
this.resetVirtualZoom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public virtualZoom(newZoom: number) {
|
||||
this.virtualZoomRatio = Math.min(Math.max(0.25, newZoom), 4);
|
||||
this.setDimensions(
|
||||
{
|
||||
width: this.virtualZoomRatio * this.getWidth() + "px",
|
||||
height: this.virtualZoomRatio * this.getHeight() + "px",
|
||||
},
|
||||
{ cssOnly: true },
|
||||
);
|
||||
}
|
||||
|
||||
public virtualZoomIn() {
|
||||
this.virtualZoom(this.virtualZoomRatio * 1.05);
|
||||
}
|
||||
|
||||
public virtualZoomOut() {
|
||||
this.virtualZoom(this.virtualZoomRatio * 0.95);
|
||||
}
|
||||
|
||||
public getVirtualZoom(): number {
|
||||
return this.virtualZoomRatio;
|
||||
}
|
||||
|
||||
public resetVirtualZoom() {
|
||||
this.virtualZoom(1);
|
||||
}
|
||||
|
||||
setLabelProps(value: LabelProps) {
|
||||
this.labelProps = value;
|
||||
this.requestRenderAll();
|
||||
}
|
||||
|
||||
setCustomBackground(value: boolean) {
|
||||
this.customBackground = value;
|
||||
}
|
||||
|
||||
setHighlightMirror(value: boolean) {
|
||||
this.highlightMirror = value;
|
||||
}
|
||||
|
||||
/** Get label bounds without tail */
|
||||
getLabelBounds(): LabelBounds {
|
||||
let endX = this.width ?? 1;
|
||||
let endY = this.height ?? 1;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
if (this.labelProps.tailPos === "right") {
|
||||
endX -= this.labelProps.tailLength ?? 0;
|
||||
} else if (this.labelProps.tailPos === "bottom") {
|
||||
endY -= this.labelProps.tailLength ?? 0;
|
||||
} else if (this.labelProps.tailPos === "left") {
|
||||
startX += this.labelProps.tailLength ?? 0;
|
||||
} else if (this.labelProps.tailPos === "top") {
|
||||
startY += this.labelProps.tailLength ?? 0;
|
||||
}
|
||||
|
||||
const width = endX - startX;
|
||||
const height = endY - startY;
|
||||
|
||||
return { startX, startY, endX, endY, width, height };
|
||||
}
|
||||
|
||||
/** Get fold line position for splitted labels */
|
||||
getFoldInfo(): FoldInfo {
|
||||
const bb = this.getLabelBounds();
|
||||
const points: number[] = [];
|
||||
const segments: FoldSegment[] = [];
|
||||
const splitParts = this.labelProps.splitParts ?? 2;
|
||||
|
||||
if (splitParts < 2) {
|
||||
return { axis: "none", points, segments };
|
||||
}
|
||||
|
||||
if (this.labelProps.split === "horizontal") {
|
||||
const segmentHeight = bb.height / splitParts;
|
||||
let lastY: number = bb.startY;
|
||||
|
||||
for (let i = 1; i < splitParts; i++) {
|
||||
const y =
|
||||
bb.startY + segmentHeight * i - this.SEPARATOR_LINE_WIDTH / 2 + 1;
|
||||
points.push(y);
|
||||
segments.push({ start: lastY, end: y });
|
||||
lastY = y;
|
||||
}
|
||||
|
||||
segments.push({ start: lastY, end: bb.endY });
|
||||
|
||||
return { axis: "horizontal", points, segments };
|
||||
} else if (this.labelProps.split === "vertical") {
|
||||
const segmentWidth = bb.width / splitParts;
|
||||
let lastX: number = bb.startX;
|
||||
|
||||
for (let i = 1; i < splitParts; i++) {
|
||||
const x =
|
||||
bb.startX + segmentWidth * i - this.SEPARATOR_LINE_WIDTH / 2 + 1;
|
||||
points.push(x);
|
||||
segments.push({ start: lastX, end: x });
|
||||
lastX = x;
|
||||
}
|
||||
|
||||
segments.push({ start: lastX, end: bb.endX });
|
||||
|
||||
return { axis: "vertical", points, segments };
|
||||
}
|
||||
|
||||
return { axis: "none", points, segments };
|
||||
}
|
||||
|
||||
override _renderBackground(ctx: CanvasRenderingContext2D) {
|
||||
if (this.width === undefined || this.height === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = "white";
|
||||
|
||||
// Draw simple white background and exit
|
||||
if (!this.customBackground) {
|
||||
ctx.fillRect(0, 0, this.width, this.height);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable further actions for circle labels, just render
|
||||
if (this.labelProps.shape === "circle") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.width / 2, this.height / 2, this.height / 2, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
let roundRadius = this.ROUND_RADIUS;
|
||||
const bb = this.getLabelBounds();
|
||||
const fold = this.getFoldInfo();
|
||||
|
||||
if (this.labelProps.shape !== "rounded_rect") {
|
||||
roundRadius = 0;
|
||||
}
|
||||
|
||||
// Draw tail
|
||||
ctx.fillStyle = this.GRAY;
|
||||
|
||||
ctx.beginPath();
|
||||
if (
|
||||
this.labelProps.tailLength !== undefined &&
|
||||
this.labelProps.tailLength > 0
|
||||
) {
|
||||
if (this.labelProps.tailPos === "right") {
|
||||
ctx.rect(
|
||||
bb.endX - roundRadius,
|
||||
bb.endY / 2 - this.TAIL_WIDTH / 2,
|
||||
this.width - bb.endX + roundRadius,
|
||||
this.TAIL_WIDTH,
|
||||
);
|
||||
} else if (this.labelProps.tailPos === "bottom") {
|
||||
ctx.rect(
|
||||
bb.endX / 2 - this.TAIL_WIDTH / 2,
|
||||
bb.endY - roundRadius,
|
||||
this.TAIL_WIDTH,
|
||||
this.height - bb.endY + roundRadius,
|
||||
);
|
||||
} else if (this.labelProps.tailPos === "left") {
|
||||
ctx.rect(
|
||||
0,
|
||||
bb.endY / 2 - this.TAIL_WIDTH / 2,
|
||||
bb.startX + roundRadius,
|
||||
this.TAIL_WIDTH,
|
||||
);
|
||||
} else if (this.labelProps.tailPos === "top") {
|
||||
ctx.rect(
|
||||
bb.endX / 2 - this.TAIL_WIDTH / 2,
|
||||
0,
|
||||
this.TAIL_WIDTH,
|
||||
bb.startY + roundRadius,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
// Draw label(s)
|
||||
ctx.fillStyle = "white";
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
const splitParts = this.labelProps.splitParts ?? 2;
|
||||
|
||||
if (this.labelProps.shape === "rounded_rect") {
|
||||
if (this.labelProps.split === "horizontal") {
|
||||
const segmentHeight = bb.height / splitParts;
|
||||
ctx.roundRect(
|
||||
bb.startX,
|
||||
bb.startY,
|
||||
bb.width,
|
||||
segmentHeight,
|
||||
roundRadius,
|
||||
); // First part
|
||||
fold.points.forEach((y) =>
|
||||
ctx.roundRect(bb.startX, y, bb.width, segmentHeight, roundRadius),
|
||||
); // Other parts
|
||||
} else if (this.labelProps.split === "vertical") {
|
||||
const segmentWidth = bb.width / splitParts;
|
||||
ctx.roundRect(
|
||||
bb.startX,
|
||||
bb.startY,
|
||||
segmentWidth,
|
||||
bb.height,
|
||||
roundRadius,
|
||||
); // First part
|
||||
fold.points.forEach((x) =>
|
||||
ctx.roundRect(x, bb.startY, segmentWidth, bb.height, roundRadius),
|
||||
); // Other parts
|
||||
} else {
|
||||
ctx.roundRect(0, 0, this.width, this.height, roundRadius);
|
||||
}
|
||||
} else {
|
||||
ctx.rect(bb.startX, bb.startY, bb.width, bb.height);
|
||||
}
|
||||
|
||||
ctx.fill();
|
||||
|
||||
// Draw separator
|
||||
|
||||
ctx.strokeStyle = this.GRAY;
|
||||
ctx.lineWidth = this.SEPARATOR_LINE_WIDTH;
|
||||
ctx.setLineDash([8, 8]);
|
||||
ctx.beginPath();
|
||||
|
||||
if (fold.axis === "horizontal") {
|
||||
fold.points.forEach((x) => {
|
||||
ctx.moveTo(bb.startX + roundRadius, x);
|
||||
ctx.lineTo(bb.endX - roundRadius, x);
|
||||
});
|
||||
} else if (fold.axis === "vertical") {
|
||||
fold.points.forEach((y) => {
|
||||
ctx.moveTo(y, bb.startY + roundRadius);
|
||||
ctx.lineTo(y, bb.endY - roundRadius);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
override _renderObjects(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
objects: fabric.FabricObject[],
|
||||
) {
|
||||
super._renderObjects(ctx, objects);
|
||||
|
||||
if (!this.highlightMirror || this.getActiveObjects().length > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
|
||||
objects.forEach((obj) => {
|
||||
const infos = this.getMirroredObjectCoords(obj);
|
||||
infos.forEach((info) => {
|
||||
const bbox = obj.getBoundingRect();
|
||||
ctx.fillStyle = this.MIRROR_GHOST_COLOR;
|
||||
ctx.fillRect(
|
||||
info.pos.x - bbox.width / 2,
|
||||
info.pos.y - bbox.height / 2,
|
||||
bbox.width,
|
||||
bbox.height,
|
||||
);
|
||||
ctx.restore();
|
||||
});
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return new object positions (origin is center) if object needs mirroring
|
||||
**/
|
||||
getMirroredObjectCoords(obj: fabric.FabricObject): MirrorInfo[] {
|
||||
const fold = this.getFoldInfo();
|
||||
const result: MirrorInfo[] = [];
|
||||
|
||||
if (
|
||||
fold.axis === "none" ||
|
||||
!(this.labelProps.mirror === "flip" || this.labelProps.mirror === "copy")
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const bounds = this.getLabelBounds();
|
||||
|
||||
if (fold.axis === "vertical") {
|
||||
if (this.labelProps.mirror === "copy") {
|
||||
fold.points.forEach((x) => {
|
||||
const pos = obj.getPointByOrigin("center", "center");
|
||||
pos.setX(x + (pos.x - bounds.startX));
|
||||
result.push({ pos, flip: false });
|
||||
});
|
||||
} else if (
|
||||
this.labelProps.mirror === "flip" &&
|
||||
fold.points.length === 1
|
||||
) {
|
||||
// Half split only supported
|
||||
const axisX = fold.points[0];
|
||||
const pos = obj.getPointByOrigin("center", "center");
|
||||
pos.setX(axisX + (axisX - pos.x));
|
||||
pos.setY(bounds.startY + bounds.endY - pos.y);
|
||||
result.push({ pos, flip: true });
|
||||
}
|
||||
} else if (fold.axis === "horizontal") {
|
||||
if (this.labelProps.mirror === "copy") {
|
||||
fold.points.forEach((y) => {
|
||||
const pos = obj.getPointByOrigin("center", "center");
|
||||
pos.setY(y + (pos.y - bounds.startY));
|
||||
result.push({ pos, flip: false });
|
||||
});
|
||||
} else if (
|
||||
this.labelProps.mirror === "flip" &&
|
||||
fold.points.length === 1
|
||||
) {
|
||||
// Half split only supported
|
||||
const axisY = fold.points[0];
|
||||
const pos = obj.getPointByOrigin("center", "center");
|
||||
pos.setY(axisY + (axisY - pos.y));
|
||||
pos.setX(bounds.startX + bounds.endX - pos.x);
|
||||
result.push({ pos, flip: true });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Clone mirrored objects and add them to canvas */
|
||||
async createMirroredObjects() {
|
||||
const objects = this.getObjects();
|
||||
for (const obj of objects) {
|
||||
const infos = this.getMirroredObjectCoords(obj);
|
||||
|
||||
for (const info of infos) {
|
||||
const newObj = await obj.clone();
|
||||
newObj.setPositionByOrigin(info.pos, "center", "center");
|
||||
if (info.flip) {
|
||||
newObj.centeredRotation = true;
|
||||
newObj.rotate((newObj.angle + 180) % 360);
|
||||
}
|
||||
this.add(newObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Centers object horizontally in the canvas or label part */
|
||||
override centerObjectH(object: fabric.FabricObject): void {
|
||||
if ((this.labelProps.split ?? "none") !== "none") {
|
||||
const pos = object.getPointByOrigin("center", "center");
|
||||
const bounds = this.getLabelBounds();
|
||||
const fold = this.getFoldInfo();
|
||||
let centerX = bounds.startX + bounds.width / 2;
|
||||
|
||||
if (fold.axis !== "horizontal") {
|
||||
fold.segments.forEach((seg) => {
|
||||
if (pos.x >= seg.start && pos.x <= seg.end) {
|
||||
centerX = seg.start + (seg.end - seg.start) / 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
pos.setX(centerX);
|
||||
|
||||
object.setPositionByOrigin(pos, "center", "center");
|
||||
return;
|
||||
}
|
||||
|
||||
super.centerObjectH(object);
|
||||
}
|
||||
|
||||
/** Centers object vertically in the canvas or label part */
|
||||
override centerObjectV(object: fabric.FabricObject): void {
|
||||
if ((this.labelProps.split ?? "none") !== "none") {
|
||||
const pos = object.getPointByOrigin("center", "center");
|
||||
const bounds = this.getLabelBounds();
|
||||
const fold = this.getFoldInfo();
|
||||
let centerY = bounds.startY + bounds.height / 2;
|
||||
|
||||
if (fold.axis !== "vertical") {
|
||||
fold.segments.forEach((seg) => {
|
||||
if (pos.y >= seg.start && pos.y <= seg.end) {
|
||||
centerY = seg.start + (seg.end - seg.start) / 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pos.setY(centerY);
|
||||
object.setPositionByOrigin(pos, "center", "center");
|
||||
return;
|
||||
}
|
||||
|
||||
super.centerObjectV(object);
|
||||
}
|
||||
}
|
||||
159
web/src/fabric-object/qrcode.ts
Normal file
159
web/src/fabric-object/qrcode.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import QRCodeFactory from "qrcode-generator";
|
||||
import * as fabric from "fabric";
|
||||
import { OBJECT_DEFAULTS_TEXT, OBJECT_SIZE_DEFAULTS } from "$/defaults";
|
||||
import { Range } from "$/types";
|
||||
|
||||
export type ErrorCorrectionLevel = "L" | "M" | "Q" | "H";
|
||||
export type Mode = "Numeric" | "Alphanumeric" | "Byte" /* Default */ | "Kanji";
|
||||
|
||||
export type QrVersion = Range<41>; // 0-40, 0 is automatic
|
||||
|
||||
export const qrCodeDefaultValues: Partial<fabric.TClassProperties<QRCode>> = {
|
||||
text: "Text",
|
||||
ecl: "M",
|
||||
stroke: "#000000",
|
||||
fill: "#ffffff",
|
||||
mode: "Byte",
|
||||
qrVersion: 0,
|
||||
...OBJECT_SIZE_DEFAULTS,
|
||||
};
|
||||
|
||||
interface UniqueQRCodeProps {
|
||||
text: string;
|
||||
ecl: ErrorCorrectionLevel;
|
||||
mode: Mode;
|
||||
qrVersion: QrVersion;
|
||||
}
|
||||
export interface QRCodeProps
|
||||
extends fabric.FabricObjectProps,
|
||||
UniqueQRCodeProps {}
|
||||
export interface SerializedQRCodeProps
|
||||
extends fabric.SerializedObjectProps,
|
||||
UniqueQRCodeProps {}
|
||||
const QRCODE_PROPS = ["text", "ecl", "size", "mode", "qrVersion"] as const;
|
||||
|
||||
export class QRCode<
|
||||
Props extends fabric.TOptions<QRCodeProps> = Partial<QRCodeProps>,
|
||||
SProps extends SerializedQRCodeProps = SerializedQRCodeProps,
|
||||
EventSpec extends fabric.ObjectEvents = fabric.ObjectEvents,
|
||||
>
|
||||
extends fabric.FabricObject<Props, SProps, EventSpec>
|
||||
implements QRCodeProps
|
||||
{
|
||||
static override readonly type = "QRCode";
|
||||
|
||||
/**
|
||||
* QRCode text
|
||||
* @type string
|
||||
* @default "Text"
|
||||
*/
|
||||
declare text: string;
|
||||
|
||||
/**
|
||||
* Error Correction Level
|
||||
* @type ErrorCorrectionLevel
|
||||
* @default "M"
|
||||
*/
|
||||
declare ecl: ErrorCorrectionLevel;
|
||||
|
||||
/**
|
||||
* Mode
|
||||
* @type Mode
|
||||
* @default "M"
|
||||
*/
|
||||
declare mode: Mode;
|
||||
|
||||
/**
|
||||
* Version
|
||||
* @type Mode
|
||||
* @default "M"
|
||||
*/
|
||||
declare qrVersion: QrVersion;
|
||||
|
||||
constructor(options?: Props) {
|
||||
super();
|
||||
Object.assign(this, qrCodeDefaultValues);
|
||||
this.setOptions(options);
|
||||
this.lockScalingFlip = true;
|
||||
this.setControlsVisibility({
|
||||
ml: false,
|
||||
mt: false,
|
||||
mr: false,
|
||||
mb: false,
|
||||
tl: false,
|
||||
tr: false,
|
||||
bl: false,
|
||||
});
|
||||
}
|
||||
|
||||
override _set(key: string, value: any): this {
|
||||
super._set(key, value);
|
||||
if (key === "text" || key === "ecl") {
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
renderError(ctx: CanvasRenderingContext2D): void {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "black";
|
||||
ctx.translate(-this.width / 2, -this.height / 2); // make top-left origin
|
||||
ctx.translate(-0.5, -0.5); // blurry rendering fix
|
||||
ctx.fillRect(0, 0, this.width + 1, this.height + 1);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = "white";
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = `16px ${OBJECT_DEFAULTS_TEXT.fontFamily}`;
|
||||
ctx.fillText("ERR", 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
override _render(ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.text) {
|
||||
this.renderError(ctx);
|
||||
super._render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const qr = QRCodeFactory(this.qrVersion, this.ecl);
|
||||
|
||||
try {
|
||||
qr.addData(this.text, this.mode);
|
||||
qr.make();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.renderError(ctx);
|
||||
super._render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const qrScale = Math.floor(this.width / qr.getModuleCount());
|
||||
let qrWidth = qrScale * qr.getModuleCount();
|
||||
qrWidth -= qrWidth % 2; // avoid half-pixel rendering
|
||||
|
||||
if (qrScale < 1 || qrWidth > this.width) {
|
||||
this.renderError(ctx);
|
||||
super._render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(-qrWidth / 2, -qrWidth / 2); // make top-left origin
|
||||
ctx.translate(-0.5, -0.5); // blurry rendering fix
|
||||
qr.renderTo2dContext(ctx, qrScale);
|
||||
ctx.restore();
|
||||
|
||||
super._render(ctx);
|
||||
}
|
||||
|
||||
override toObject(propertiesToInclude: any[] = []) {
|
||||
return super.toObject([...QRCODE_PROPS, ...propertiesToInclude]);
|
||||
}
|
||||
}
|
||||
|
||||
fabric.classRegistry.setClass(QRCode, "QRCode");
|
||||
|
||||
export default QRCode;
|
||||
89
web/src/fabric-object/textbox-ext.ts
Normal file
89
web/src/fabric-object/textbox-ext.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as fabric from "fabric";
|
||||
|
||||
interface UniqueTextboxExtProps {
|
||||
fontAutoSize: boolean;
|
||||
}
|
||||
|
||||
const TEXTBOX_PROPS: Array<keyof UniqueTextboxExtProps> = ["fontAutoSize"];
|
||||
|
||||
export const textboxExtDefaultValues: Partial<fabric.TClassProperties<TextboxExt>> = {
|
||||
fontAutoSize: false,
|
||||
};
|
||||
|
||||
export interface TextboxExtProps extends fabric.TextboxProps, UniqueTextboxExtProps {}
|
||||
export interface SerializedTextboxExtProps extends fabric.SerializedTextboxProps, UniqueTextboxExtProps {}
|
||||
|
||||
export class TextboxExt<
|
||||
Props extends fabric.TOptions<TextboxExtProps> = Partial<TextboxExtProps>,
|
||||
SProps extends SerializedTextboxExtProps = SerializedTextboxExtProps,
|
||||
EventSpec extends fabric.ITextEvents = fabric.ITextEvents,
|
||||
>
|
||||
extends fabric.Textbox<Props, SProps, EventSpec>
|
||||
implements UniqueTextboxExtProps
|
||||
{
|
||||
declare fontAutoSize: boolean;
|
||||
|
||||
private widthBeforeEditing?: number;
|
||||
|
||||
constructor(text: string, options?: Props) {
|
||||
super(text, options);
|
||||
Object.assign(this, textboxExtDefaultValues);
|
||||
this.setOptions(options);
|
||||
|
||||
this.setControlsVisibility({
|
||||
mb: false,
|
||||
mt: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Set text and reduce fontSize until text fits to the given width */
|
||||
setAndShrinkText(text: string, maxWidth: number, maxLines?: number) {
|
||||
const linesLimit = maxLines ?? this._splitTextIntoLines(this.text).lines.length;
|
||||
|
||||
let linesCount = this._splitTextIntoLines(text).lines.length;
|
||||
|
||||
this.set({ text });
|
||||
|
||||
while ((linesCount > linesLimit || this.width > maxWidth) && this.fontSize > 2) {
|
||||
this.fontSize -= 1;
|
||||
this.set({ text, width: maxWidth });
|
||||
linesCount = this._splitTextIntoLines(text).lines.length;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reduce fontSize until text fits to the given width */
|
||||
shrinkText(maxWidth: number, maxLines: number) {
|
||||
let linesCount = this._splitTextIntoLines(this.text).lines.length;
|
||||
|
||||
while ((linesCount > maxLines || this.width > maxWidth) && this.fontSize > 2) {
|
||||
this.fontSize -= 1;
|
||||
this.set({ width: maxWidth });
|
||||
linesCount = this._splitTextIntoLines(this.text).lines.length;
|
||||
}
|
||||
}
|
||||
|
||||
override enterEditingImpl() {
|
||||
super.enterEditingImpl();
|
||||
this.widthBeforeEditing = this.width;
|
||||
}
|
||||
|
||||
override exitEditingImpl() {
|
||||
super.exitEditingImpl();
|
||||
this.widthBeforeEditing = undefined;
|
||||
}
|
||||
|
||||
override updateFromTextArea(): void {
|
||||
super.updateFromTextArea();
|
||||
|
||||
if (this.widthBeforeEditing !== undefined && this.fontAutoSize) {
|
||||
const lines = this.text.split("\n").length;
|
||||
this.shrinkText(this.widthBeforeEditing, lines);
|
||||
}
|
||||
}
|
||||
|
||||
override toObject<T extends Omit<Props & fabric.TClassProperties<this>, keyof SProps>, K extends keyof T = never>(
|
||||
propertiesToInclude: K[] = [],
|
||||
): Pick<T, K> & SProps {
|
||||
return super.toObject([...propertiesToInclude, ...TEXTBOX_PROPS] as (keyof T)[]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user