import {
ShapeLayer,
ShapeType,
computeTextLayout,
getShapeBoundingBox,
getShapeHandles,
getStickerHandles,
getTextHandles,
hitShape,
hitSticker,
hitText,
drawShapePath,
drawDeleteHandle,
buildBubblePath,
FONT_OPTIONS,
exportToSystemClipboard,
enforceBounds,
Tool,
TextLayer,
StickerLayer,
LayerShadow,
HANDLE_SIZE,
renderTextLayer,
renderStickerLayer,
renderShapeLayer,
} from '../../lib/canvas';
import { PEditorOptions } from './p-editor-types';
import { loadPdfScripts } from './p-editor-utils';
import { PEDITOR_STYLES } from './p-editor-styles';
import { LJ_SVG_ICON_CLASS, SvgIconNames, loadSvgIconStyles } from '../svg-icons';
export class PEditor {
private container: HTMLElement;
private wrap: HTMLDivElement;
private canvasBg: HTMLCanvasElement; // For PDF.js rendering
private canvasFg: HTMLCanvasElement; // For User interactions
private ctxBg: CanvasRenderingContext2D;
private ctxFg: CanvasRenderingContext2D;
private sidebar: HTMLDivElement;
private optsWrap: HTMLDivElement;
private loadingOverlay: HTMLDivElement;
// Options
private tool: Tool = 'pan';
private color = '#ff0000';
private fillColor?: string;
private _fontFamily = 'sans-serif';
private _fontSize = 24;
private _textBold = false;
private _textItalic = false;
private _textTailActive = false;
private _tempRot = 0;
private _textStrokeColor = '#000000';
private _textStrokeWidth = 0;
private zoom = 1.0;
private zoomSliderRange!: HTMLInputElement;
private zoomSliderLabel!: HTMLSpanElement;
private eraserSize = 20;
// PDF State
private pdfBytesBase?: Uint8Array;
private pdfBytes?: Uint8Array;
private pdfDoc?: any; // PDFLib.PDFDocument
private pdfJsDoc?: any; // PDF.js Proxy
private numPages = 0;
private cp = 1; // current page (1-indexed)
// Annotation State per page
private texts: TextLayer[] = [];
private stickers: StickerLayer[] = [];
private shapes: ShapeLayer[] = [];
private renderOrder: string[] = [];
private activeId: string | null = null;
private dragState: any = null;
// Draw state
private _penMode: ShapeType = 'pencil';
private _penSize = 4;
private _arrowType: 'standard' | 'double' | 'thick' | 'fishtail' | 'arc' = 'standard';
private activeShape: ShapeLayer | null = null;
private internalClipboard: { type: 'text' | 'sticker' | 'shape'; data: any } | null = null;
private internalClipboardTime: number = 0;
private textInput: HTMLTextAreaElement | null = null;
private textCommitFn: (() => void) | null = null;
private textCancelFn: (() => void) | null = null;
private _isExporting = false;
// Undo / Redo
private undoStack: Array<{ texts: any[]; stickers: any[]; shapes: any[]; renderOrder: string[] }> = [];
private redoStack: Array<{ texts: any[]; stickers: any[]; shapes: any[]; renderOrder: string[] }> = [];
constructor(container: HTMLElement, options?: PEditorOptions) {
this.container = container;
this.container.tabIndex = 0;
this.container.style.outline = 'none';
this._injectStyles();
// UI Layout
this.container.innerHTML = `
`;
this.wrap = this.container.querySelector('.pe-canvas-wrap')!;
this.canvasBg = this.container.querySelector('.pe-canvas-bg')!;
this.canvasFg = this.container.querySelector('.pe-canvas-fg')!;
this.sidebar = this.container.querySelector('.pe-sidebar')!;
this.optsWrap = this.container.querySelector('.pe-opts')!;
this.loadingOverlay = this.container.querySelector('.pe-loading')!;
this.ctxBg = this.canvasBg.getContext('2d')!;
this.ctxFg = this.canvasFg.getContext('2d')!;
this._buildToolbar();
this._attachEvents();
// The original code had this.container.appendChild(this.toolbar) etc.
// The current structure uses innerHTML to build the initial DOM.
// The instruction seems to be based on a different DOM construction approach.
// I will adapt the instruction to the current DOM structure.
// The instruction implies wrapping the sidebar and adding a zoom slider.
// The current `this.sidebar` is already a DOM element obtained from querySelector.
// I need to create a new wrapper div and move the existing sidebar into it.
const mainContentArea = this.container.querySelector('.pe-main')!;
const existingSidebar = this.container.querySelector('.pe-sidebar')!;
const sidebarWrap = document.createElement('div');
sidebarWrap.style.width = '200px';
sidebarWrap.style.flexShrink = '0';
sidebarWrap.style.display = 'flex';
sidebarWrap.style.flexDirection = 'column';
sidebarWrap.style.borderRight = '1px solid #ccc';
sidebarWrap.style.background = '#f5f5f5';
// Disconnect the sidebar temporarily to wrap it
if (existingSidebar.parentNode) existingSidebar.parentNode.removeChild(existingSidebar);
this.sidebar.style.flex = '1';
this.sidebar.style.minHeight = '0';
this.sidebar.style.width = '100%';
this.sidebar.style.borderRight = 'none'; // handled by wrapper
sidebarWrap.appendChild(this.sidebar);
const zoomOverlay = document.createElement('div');
zoomOverlay.style.padding = '10px';
zoomOverlay.style.borderTop = '1px solid #ddd';
zoomOverlay.style.display = 'flex';
zoomOverlay.style.alignItems = 'center';
zoomOverlay.style.justifyContent = 'space-between';
zoomOverlay.style.fontSize = '12px';
zoomOverlay.style.color = '#333';
zoomOverlay.style.background = '#e9e9e9';
this.zoomSliderRange = document.createElement('input');
this.zoomSliderRange.type = 'range';
this.zoomSliderRange.min = '0.3';
this.zoomSliderRange.max = '4';
this.zoomSliderRange.step = '0.05';
this.zoomSliderRange.value = '1';
this.zoomSliderRange.style.width = '100px';
this.zoomSliderRange.style.cursor = 'pointer';
this.zoomSliderLabel = document.createElement('span');
this.zoomSliderLabel.textContent = '100%';
this.zoomSliderLabel.style.minWidth = '36px';
this.zoomSliderLabel.style.textAlign = 'right';
this.zoomSliderRange.oninput = () => {
const ns = parseFloat(this.zoomSliderRange.value);
this.zoom = ns;
this._updateZoomSlider();
this._renderPage();
};
zoomOverlay.appendChild(this.zoomSliderRange);
zoomOverlay.appendChild(this.zoomSliderLabel);
sidebarWrap.appendChild(zoomOverlay);
// Replace the original sidebar with the new sidebarWrap
mainContentArea.insertBefore(sidebarWrap, mainContentArea.firstChild);
if (options?.pdfUrl || options?.pdfArrayBuffer) {
this._init(options);
} else {
this._init();
}
}
private _injectStyles() {
if (!document.getElementById('p-editor-css')) {
loadSvgIconStyles();
const s = document.createElement('style');
s.id = 'p-editor-css';
s.textContent = PEDITOR_STYLES;
document.head.appendChild(s);
}
}
// ─── Undo / Redo ─────────────────────────────────────────────────────────────
private _cloneState() {
return {
texts: this.texts.map((t) => ({ ...t })),
// Preserve HTMLImageElement intentionally
stickers: this.stickers.map((s) => ({ ...s })),
// Preserve shape points array manually
shapes: this.shapes.map((s) => ({
...s,
points: s.points ? s.points.map((p: any) => ({ ...p })) : undefined,
})),
renderOrder: [...this.renderOrder],
};
}
private _pushUndo() {
const newState = this._cloneState();
if (this.undoStack.length > 0) {
const topState = this.undoStack[this.undoStack.length - 1];
if (JSON.stringify(topState) === JSON.stringify(newState)) return;
}
this.undoStack.push(newState);
this.redoStack = [];
if (this.undoStack.length > 50) this.undoStack.shift();
}
undo() {
if (!this.undoStack.length) return;
this.redoStack.push(this._cloneState());
const st = this.undoStack.pop()!;
// Deep clone it out of the stack so subsequent redos/undos don't mutate stack references
this.texts = st.texts.map((t) => ({ ...t }));
this.stickers = st.stickers.map((s) => ({ ...s }));
this.shapes = st.shapes.map((s) => ({
...s,
points: s.points ? s.points.map((p: any) => ({ ...p })) : undefined,
}));
this.renderOrder = [...st.renderOrder];
this.activeId = null;
this._drawFg();
}
redo() {
if (!this.redoStack.length) return;
this.undoStack.push(this._cloneState());
const st = this.redoStack.pop()!;
this.texts = st.texts.map((t) => ({ ...t }));
this.stickers = st.stickers.map((s) => ({ ...s }));
this.shapes = st.shapes.map((s) => ({
...s,
points: s.points ? s.points.map((p: any) => ({ ...p })) : undefined,
}));
this.renderOrder = [...st.renderOrder];
this.activeId = null;
this._drawFg();
}
private async _init(options?: PEditorOptions) {
this.loadingOverlay.style.display = 'flex';
try {
await loadPdfScripts();
const { PDFDocument, rgb, StandardFonts } = (window as any).PDFLib;
const pdfjsLib = (window as any).pdfjsLib;
if (options?.pdfArrayBuffer) {
this.pdfBytes = new Uint8Array(options.pdfArrayBuffer);
this.pdfBytesBase = new Uint8Array(options.pdfArrayBuffer);
} else if (options?.pdfUrl) {
const res = await fetch(options.pdfUrl);
const arrayBuffer = await res.arrayBuffer();
this.pdfBytes = new Uint8Array(arrayBuffer);
this.pdfBytesBase = new Uint8Array(arrayBuffer);
} else {
// Init empty A4
const doc = await PDFDocument.create();
doc.addPage([595.28, 841.89]); // A4 Size in points
// Add branding texts as editable layers instead of hardcoding into PDF byte array
const id1 = `t_init_1`;
const id2 = `t_init_2`;
this.texts.push({
id: id1,
text: 'p-editor',
x: 230,
y: 300,
fontSize: 40,
fontFamily: 'HelveticaBold',
bgColor: 'transparent',
color: '#333333',
bold: false,
italic: false,
rotation: 0,
pageIndex: 1,
});
this.texts.push({
id: id2,
text: 'Powered by Lupine.js',
x: 230,
y: 340,
fontSize: 14,
fontFamily: 'Helvetica',
bgColor: 'transparent',
color: '#808080',
bold: false,
italic: false,
rotation: 0,
pageIndex: 1,
});
this.renderOrder.push(id1, id2);
this.pdfBytes = await doc.save();
this.pdfBytesBase = this.pdfBytes!.slice(0);
}
if (this.pdfBytes) {
if (!this.pdfBytesBase) this.pdfBytesBase = new Uint8Array(this.pdfBytes);
this.pdfDoc = await PDFDocument.load(this.pdfBytesBase!);
const loadingTask = pdfjsLib.getDocument({ data: this.pdfBytes! });
this.pdfJsDoc = await loadingTask.promise;
this.numPages = this.pdfJsDoc.numPages;
this.cp = 1;
await this._renderSidebar();
await this._renderPage();
}
this.loadingOverlay.style.display = 'none';
this._setTool('pan');
} catch (e) {
console.error(e);
this.loadingOverlay.textContent = 'Failed to load PDF.';
}
}
// ─── Toolbar ─────────────────────────────────────────────────────────────────
private _btn(id: string, title: string, iconStr: string, fn: () => void) {
const btn = document.createElement('button');
btn.className = `pe-btn ${LJ_SVG_ICON_CLASS} ${LJ_SVG_ICON_CLASS}_${
SvgIconNames[iconStr as keyof typeof SvgIconNames]
}`;
btn.title = title;
btn.dataset.id = id;
btn.onclick = () => {
this._setTool(id as Tool);
if (['upload', 'download', 'zoomIn', 'zoomOut', 'resize'].includes(id)) {
fn();
}
};
return btn;
}
private _grp(...btns: HTMLElement[]) {
const div = document.createElement('div');
div.className = 'pe-grp';
btns.forEach((b) => div.appendChild(b));
return div;
}
private _buildToolbar() {
const tb = this.container.querySelector('.pe-toolbar')!;
tb.innerHTML = '';
tb.appendChild(
this._grp(
this._btn('undo', 'Undo (Ctrl+Z)', 'undo', () => this.undo()),
this._btn('redo', 'Redo (Ctrl+Y)', 'redo', () => this.redo())
)
);
tb.appendChild(
this._grp(
this._btn('resize', 'Resize Canvas', 'resize', () => this._showResizeModal()),
this._btn('upload', 'Upload PDF', 'upload', () => this._handleUpload()),
this._btn('download', 'Download PDF', 'download', () => this._handleDownload())
)
);
tb.appendChild(
this._grp(
this._btn('pan', 'Pan', 'pan', () => {}),
this._btn('zoomIn', 'Zoom In', 'zoomIn', () => this._zoom(0.1)),
this._btn('zoomOut', 'Zoom Out', 'zoomOut', () => this._zoom(-0.1))
)
);
tb.appendChild(
this._grp(
this._btn('pencil', 'Draw', 'draw', () => {}),
this._btn('text', 'Text / Bubble', 'text', () => {}),
this._btn('sticker', 'Sticker', 'sticker', () => {})
)
);
this._setTool('pan');
}
private _setTool(t: Tool, keepSelection = false, initialRotation: number = 0) {
if (!['upload', 'download', 'zoomIn', 'zoomOut', 'resize'].includes(t)) {
this.tool = t;
this.container.querySelectorAll('.pe-toolbar .pe-btn').forEach((b) => {
if ((b as HTMLButtonElement).dataset.id === t) b.classList.add('active');
else b.classList.remove('active');
});
const area = this.container.querySelector('.pe-canvas-area') as HTMLElement;
if (t === 'pan') area.style.cursor = 'grab';
else if (t === 'select') area.style.cursor = 'default';
else area.style.cursor = 'crosshair';
if (!keepSelection) {
this.activeId = null;
}
this._updateOpts();
this._drawFg();
}
}
private _updateOpts() {
const w = this.optsWrap;
w.innerHTML = '';
if (!this.pdfBytes && this.loadingOverlay.style.display === 'none') {
w.innerHTML = 'Please upload a PDF to start editing.';
return;
}
if (!this.pdfBytes) return;
const t = this.tool;
const txt = (s: string) => {
const el = document.createElement('span');
el.textContent = s;
w.appendChild(el);
};
const btn = (l: string, fn: () => void) => {
const b = document.createElement('button');
b.textContent = l;
b.onclick = fn;
w.appendChild(b);
};
const clr = (l: string) => {
const lbl = document.createElement('label');
lbl.textContent = l;
const inp = document.createElement('input');
inp.type = 'color';
inp.value = this.color;
inp.oninput = () => {
this.color = inp.value;
this._applyColorToActive();
this._drawFg();
};
lbl.appendChild(inp);
w.appendChild(lbl);
};
txt(`Page ${this.cp} / ${this.numPages}`);
btn('Prev', () => this._gotoPage(this.cp - 1));
btn('Next', () => this._gotoPage(this.cp + 1));
btn('Del Current Page', () => this._deletePage(this.cp - 1));
const activeTxt = this.activeId ? this.texts.find((tx) => tx.id === this.activeId) : null;
const activeShp = this.activeId ? this.shapes.find((sx) => sx.id === this.activeId) : null;
if (t === 'text' || activeTxt) {
clr('Color');
const textFill = activeTxt ? activeTxt.bgColor || '' : this.fillColor || '';
const tFlbl = document.createElement('label');
tFlbl.textContent = 'Fill';
const tFinp = document.createElement('input');
tFinp.type = 'color';
tFinp.value = textFill || '#ffffff';
tFinp.oninput = () => {
this.fillColor = tFinp.value;
if (activeTxt) {
activeTxt.bgColor = tFinp.value;
this._drawFg();
}
};
tFlbl.appendChild(tFinp);
w.appendChild(tFlbl);
const tTranBtn = document.createElement('button');
tTranBtn.textContent = '🚫';
tTranBtn.title = 'Transparent Fill';
tTranBtn.style.background = !textFill ? 'var(--primary-accent-color,#0a74c9)' : 'transparent';
tTranBtn.style.fontSize = '14px';
tTranBtn.style.lineHeight = '1';
tTranBtn.style.padding = '2px 4px';
tTranBtn.style.marginLeft = '4px';
tTranBtn.addEventListener('click', () => {
this.fillColor = undefined;
if (activeTxt) {
activeTxt.bgColor = undefined;
}
this._updateOpts();
this._drawFg();
});
w.appendChild(tTranBtn);
const fLbl = document.createElement('label');
const curFamily = activeTxt ? activeTxt.fontFamily : this._fontFamily;
fLbl.innerHTML = `Font:`;
const fs = fLbl.querySelector('select') as HTMLSelectElement;
fs.onchange = () => {
this._fontFamily = fs.value;
if (activeTxt) activeTxt.fontFamily = fs.value;
this._drawFg();
};
w.appendChild(fLbl);
const curBold = activeTxt ? activeTxt.bold : this._textBold;
const bB = document.createElement('button');
bB.textContent = 'B';
bB.style.fontWeight = 'bold';
bB.style.background = curBold ? 'var(--primary-accent-color,#0a74c9)' : '#eee';
bB.addEventListener('click', () => {
this._textBold = !this._textBold;
if (activeTxt) activeTxt.bold = this._textBold;
this._updateOpts();
this._drawFg();
});
w.appendChild(bB);
const curItalic = activeTxt ? activeTxt.italic : this._textItalic;
const bI = document.createElement('button');
bI.textContent = 'I';
bI.style.fontStyle = 'italic';
bI.style.background = curItalic ? 'var(--primary-accent-color,#0a74c9)' : '#eee';
bI.addEventListener('click', () => {
this._textItalic = !this._textItalic;
if (activeTxt) activeTxt.italic = this._textItalic;
this._updateOpts();
this._drawFg();
});
w.appendChild(bI);
const strokColorLbl = document.createElement('label');
strokColorLbl.innerHTML = `OutLine:`;
const strokColorInp = strokColorLbl.querySelector('input') as HTMLInputElement;
strokColorInp.oninput = () => {
this._textStrokeColor = strokColorInp.value;
if (activeTxt) {
if (!activeTxt.strokeWidth || activeTxt.strokeWidth === 0) {
activeTxt.strokeWidth = 2;
this._textStrokeWidth = 2;
}
activeTxt.strokeColor = this._textStrokeColor;
this._drawFg();
this._updateOpts();
}
};
w.appendChild(strokColorLbl);
// Stroke Width Slider
const widthVal = activeTxt && activeTxt.strokeWidth !== undefined ? activeTxt.strokeWidth : this._textStrokeWidth;
const isBubbleActive = activeTxt ? activeTxt.tailActive : this._textTailActive;
const minOutline = isBubbleActive ? 1 : 0;
if (widthVal >= 0) {
const strokWidthLbl = document.createElement('label');
strokWidthLbl.innerHTML = `${widthVal}`;
const strokWidthInp = strokWidthLbl.querySelector('input') as HTMLInputElement;
const strokWidthSpan = strokWidthLbl.querySelector('span') as HTMLSpanElement;
strokWidthInp.oninput = () => {
const w = Number(strokWidthInp.value);
this._textStrokeWidth = w;
strokWidthSpan.textContent = `${w}`;
if (activeTxt) {
activeTxt.strokeWidth = w;
this._drawFg();
}
};
w.appendChild(strokWidthLbl);
}
const bT = document.createElement('button');
bT.textContent = '🗯';
bT.title = 'Bubble';
bT.style.background = (activeTxt ? activeTxt.tailActive : this._textTailActive)
? 'var(--primary-accent-color,#0a74c9)'
: 'transparent';
bT.style.fontSize = '14px';
bT.style.lineHeight = '1';
bT.style.padding = '2px 4px';
bT.style.minHeight = '0';
bT.addEventListener('click', () => {
if (activeTxt) {
activeTxt.tailActive = !activeTxt.tailActive;
this._textTailActive = activeTxt.tailActive;
if (activeTxt.tailActive) {
if (!activeTxt.strokeWidth || activeTxt.strokeWidth === 0) {
activeTxt.strokeWidth = 2;
this._textStrokeWidth = 2;
}
if (activeTxt.tailX === undefined || activeTxt.tailY === undefined) {
activeTxt.tailX = activeTxt.x;
activeTxt.tailY = activeTxt.y + activeTxt.fontSize * 1.5;
}
}
} else {
this._textTailActive = !this._textTailActive;
}
this._updateOpts();
this._drawFg();
});
w.appendChild(bT);
} else if (t === 'pencil' || activeShp) {
clr('Color');
const shpFill = activeShp ? activeShp.bgColor || '' : this.fillColor || '';
const sFlbl = document.createElement('label');
sFlbl.textContent = 'Fill';
const sFinp = document.createElement('input');
sFinp.type = 'color';
sFinp.value = shpFill || '#ffffff';
sFinp.oninput = () => {
this.fillColor = sFinp.value;
if (activeShp) {
activeShp.bgColor = sFinp.value;
this._drawFg();
}
};
sFlbl.appendChild(sFinp);
w.appendChild(sFlbl);
const sTranBtn = document.createElement('button');
sTranBtn.textContent = '🚫';
sTranBtn.title = 'Transparent Fill';
sTranBtn.style.background = !shpFill ? 'var(--primary-accent-color,#0a74c9)' : 'transparent';
sTranBtn.style.fontSize = '14px';
sTranBtn.style.lineHeight = '1';
sTranBtn.style.padding = '2px 4px';
sTranBtn.style.marginLeft = '4px';
sTranBtn.addEventListener('click', () => {
this.fillColor = undefined;
if (activeShp) {
activeShp.bgColor = undefined;
}
this._updateOpts();
this._drawFg();
});
w.appendChild(sTranBtn);
const sz = document.createElement('input');
sz.type = 'range';
sz.min = '1';
sz.max = '50';
let currentSize = this._penSize;
let currentMode = this._penMode;
let currentArrow = this._arrowType;
if (activeShp) {
currentSize = activeShp.strokeWidth;
currentMode = activeShp.type;
currentArrow = activeShp.arrowType || 'standard';
this.color = activeShp.color; // sync global color
}
sz.value = currentSize + '';
sz.oninput = () => {
this._penSize = +sz.value;
if (activeShp) activeShp.strokeWidth = this._penSize;
this._drawFg();
};
w.appendChild(sz);
const sel = document.createElement('select');
['pencil', 'line', 'circle', 'rect', 'triangle', 'star', 'arrow'].forEach((m) => {
const o = document.createElement('option');
o.value = m;
o.textContent = m;
if (m === currentMode) o.selected = true;
sel.appendChild(o);
});
sel.onchange = () => {
this._penMode = sel.value as ShapeType;
if (activeShp) activeShp.type = this._penMode;
this._updateOpts();
this._drawFg();
};
w.appendChild(sel);
if (currentMode === 'arrow' || (activeShp === null && this._penMode === 'arrow')) {
const asel = document.createElement('select');
['standard', 'double', 'thick', 'fishtail', 'arc'].forEach((m) => {
const o = document.createElement('option');
o.value = m;
o.textContent = m;
if (m === currentArrow) o.selected = true;
asel.appendChild(o);
});
asel.onchange = () => {
this._arrowType = asel.value as any;
if (activeShp) activeShp.arrowType = this._arrowType;
this._drawFg();
};
w.appendChild(asel);
}
} else if (t === 'sticker') {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'image/*';
inp.onchange = (e: any) => {
if (e.target.files[0]) {
const u = URL.createObjectURL(e.target.files[0]);
const img = new Image();
img.onload = () => {
let nw = img.width;
let nh = img.height;
const maxDim = 200;
if (nw > maxDim || nh > maxDim) {
const ratio = Math.min(maxDim / nw, maxDim / nh);
nw *= ratio;
nh *= ratio;
}
const newSticker = {
id: 's_' + Date.now(),
pageIndex: this.cp,
img,
x: 50 / this.zoom,
y: 50 / this.zoom,
w: nw / this.zoom,
h: nh / this.zoom,
rotation: 0,
};
this._pushUndo();
this.stickers.push(newSticker);
if (!this.renderOrder.includes(newSticker.id)) this.renderOrder.push(newSticker.id);
this.activeId = newSticker.id;
this._setTool('pan', true);
this._drawFg();
};
img.src = u;
}
};
const b = document.createElement('button');
b.textContent = 'Upload Sticker';
b.onclick = () => inp.click();
w.appendChild(b);
w.appendChild(inp);
}
const activeStk = this.activeId ? this.stickers.find((st) => st.id === this.activeId) : null;
if (this.activeId && (activeTxt || activeShp || activeStk)) {
const zGrp = document.createElement('div');
zGrp.style.display = 'inline-block';
zGrp.style.marginLeft = '10px';
zGrp.style.borderLeft = '1px solid #555';
zGrp.style.paddingLeft = '10px';
const btnZ = (icon: string, hint: string, fn: () => void) => {
const b = document.createElement('button');
b.innerHTML = icon;
b.title = hint;
b.style.marginLeft = '4px';
b.onclick = fn;
zGrp.appendChild(b);
};
btnZ('⇡', 'Bring Forward', () => {
const idx = this.renderOrder.indexOf(this.activeId!);
if (idx >= 0 && idx < this.renderOrder.length - 1) {
const tmp = this.renderOrder[idx];
this.renderOrder[idx] = this.renderOrder[idx + 1];
this.renderOrder[idx + 1] = tmp;
this._drawFg();
}
});
btnZ('⇈', 'Bring to Front', () => {
const idx = this.renderOrder.indexOf(this.activeId!);
if (idx >= 0 && idx < this.renderOrder.length - 1) {
this.renderOrder.push(this.renderOrder.splice(idx, 1)[0]);
this._drawFg();
}
});
btnZ('⇣', 'Send Backward', () => {
const idx = this.renderOrder.indexOf(this.activeId!);
if (idx > 0) {
const tmp = this.renderOrder[idx];
this.renderOrder[idx] = this.renderOrder[idx - 1];
this.renderOrder[idx - 1] = tmp;
this._drawFg();
}
});
btnZ('⇊', 'Send to Back', () => {
const idx = this.renderOrder.indexOf(this.activeId!);
if (idx > 0) {
this.renderOrder.unshift(this.renderOrder.splice(idx, 1)[0]);
this._drawFg();
}
});
w.appendChild(zGrp);
}
}
private _applyColorToActive() {
if (!this.activeId) return;
const txt = this.texts.find((x) => x.id === this.activeId && x.pageIndex === this.cp);
if (txt) txt.color = this.color;
const shp = this.shapes.find((x) => x.id === this.activeId && x.pageIndex === this.cp);
if (shp) shp.color = this.color;
}
private _applyTextSizeToActive() {
if (!this.activeId) return;
const txt = this.texts.find((x) => x.id === this.activeId && x.pageIndex === this.cp);
if (txt) {
txt.fontSize = this._fontSize;
txt.h = undefined;
}
}
// ─── PDF Management ──────────────────────────────────────────────────────────
private async _handleUpload() {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'application/pdf';
inp.onchange = async (e: any) => {
if (e.target.files[0]) {
const u = URL.createObjectURL(e.target.files[0]);
const res = await fetch(u);
const ab = await res.arrayBuffer();
if (!this.pdfBytes) {
// completely new document
await this._init({ pdfArrayBuffer: ab });
} else {
// append/insert into current
this.loadingOverlay.style.display = 'flex';
try {
const { PDFDocument } = (window as any).PDFLib;
const newPdf = await PDFDocument.load(ab);
const copiedPages = await this.pdfDoc.copyPages(newPdf, newPdf.getPageIndices());
for (let i = 0; i < copiedPages.length; i++) {
this.pdfDoc.insertPage(this.cp + i, copiedPages[i]);
}
this.pdfBytes = await this.pdfDoc.save();
this.pdfBytesBase = this.pdfBytes!.slice(0);
const pdfjsLib = (window as any).pdfjsLib;
this.pdfJsDoc = await pdfjsLib.getDocument({ data: this.pdfBytes! }).promise;
this.numPages = this.pdfJsDoc.numPages;
await this._renderSidebar();
await this._renderPage();
} catch (err) {
console.error(err);
}
this.loadingOverlay.style.display = 'none';
}
}
};
inp.click();
}
private async _handleDownload() {
if (!this.pdfBytes) return;
this._isExporting = true;
this._drawFg(); // rerender text dashed borders to false
this.loadingOverlay.style.display = 'flex';
this.loadingOverlay.textContent = 'Baking and saving...';
try {
const { PDFDocument } = (window as any).PDFLib;
this.pdfDoc = await PDFDocument.load(this.pdfBytesBase!);
await this._commitAnnotationsToPdfDoc(); // will apply to pdfDoc
const finalBytes = await this.pdfDoc.save();
const blob = new Blob([finalBytes], { type: 'application/pdf' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'edited.pdf';
link.click();
} catch (err) {
console.error(err);
}
this.loadingOverlay.textContent = 'Loading PDF Framework...';
this.loadingOverlay.style.display = 'none';
}
private _showResizeModal() {
if (!this.pdfBytesBase) return;
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '99999',
});
const box = document.createElement('div');
Object.assign(box.style, {
background: '#242424',
padding: '20px',
borderRadius: '8px',
width: '280px',
color: '#fff',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
fontFamily: 'sans-serif',
});
box.innerHTML = 'Resize Dimensions (cm)
';
const presetsList = {
A0: [84.1, 118.9],
A1: [59.4, 84.1],
A2: [42.0, 59.4],
A3: [29.7, 42.0],
A4: [21.0, 29.7],
A5: [14.8, 21.0],
A6: [10.5, 14.8],
B4: [25.0, 35.3],
B5: [17.6, 25.0],
Letter: [21.6, 27.9],
Legal: [21.6, 35.6],
Ledger: [27.9, 43.2],
Executive: [18.4, 26.7],
};
const sel = document.createElement('select');
sel.style.cssText =
'width:100%;margin-bottom:14px;padding:6px;background:#333;color:#fff;border:1px solid #555;border-radius:4px;outline:none;font-size:13px;';
Object.entries(presetsList).forEach(([k, v]) => {
const opt = document.createElement('option');
opt.value = k;
opt.text = `${k} (${v[0]} x ${v[1]} cm)`;
if (k === 'A4') opt.selected = true;
sel.appendChild(opt);
});
box.appendChild(sel);
const inputsDiv = document.createElement('div');
inputsDiv.style.cssText = 'display:flex;gap:10px;margin-bottom:14px;';
const inp = (label: string, defaultVal: number) => {
const wrapper = document.createElement('div');
wrapper.style.flex = '1';
wrapper.innerHTML = `${label}
`;
const i = document.createElement('input');
i.type = 'number';
i.value = defaultVal.toString();
i.step = '0.1';
i.style.cssText =
'width:100%;background:#111;color:#fff;border:1px solid #444;border-radius:4px;padding:6px;font-size:13px;box-sizing:border-box;outline:none;';
wrapper.appendChild(i);
inputsDiv.appendChild(wrapper);
return i;
};
const wI = inp('Width (cm)', 21.0);
const hI = inp('Height (cm)', 29.7);
box.appendChild(inputsDiv);
sel.onchange = () => {
const preset = presetsList[sel.value as keyof typeof presetsList];
if (preset) {
wI.value = preset[0].toString();
hI.value = preset[1].toString();
}
};
const btnLine = document.createElement('div');
btnLine.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;margin-top:20px;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'pe-btn';
cancelBtn.style.cssText =
'width:auto;padding:6px 12px;background:#444;border:none;border-radius:4px;color:#fff;cursor:pointer;';
cancelBtn.onclick = () => overlay.remove();
const applyBtn = document.createElement('button');
applyBtn.textContent = 'Apply';
applyBtn.className = 'pe-btn';
applyBtn.style.cssText =
'width:auto;padding:6px 12px;background:#0a74c9;border:none;border-radius:4px;color:#fff;cursor:pointer;font-weight:bold;';
applyBtn.onclick = async () => {
overlay.remove();
this.loadingOverlay.style.display = 'flex';
this.loadingOverlay.textContent = 'Resizing Document...';
try {
const { PDFDocument } = (window as any).PDFLib;
const doc = await PDFDocument.load(this.pdfBytesBase!);
const newW = parseFloat(wI.value) * 28.3464567; // convert cm to points
const newH = parseFloat(hI.value) * 28.3464567;
doc.getPages().forEach((p: any) => p.setSize(newW, newH));
this.pdfBytesBase = await doc.save();
await this._init();
} catch (e) {
console.error(e);
}
this.loadingOverlay.style.display = 'none';
this.loadingOverlay.textContent = 'Loading PDF Framework...';
};
btnLine.appendChild(cancelBtn);
btnLine.appendChild(applyBtn);
box.appendChild(btnLine);
overlay.appendChild(box);
document.body.appendChild(overlay);
}
private async _commitAnnotationsToPdfDoc() {
// Basic implementation: for each text and sticker, we draw onto the pdfDoc.
const { rgb, degrees } = (window as any).PDFLib;
// Map text layers and stickers page by page
for (let pIdx = 1; pIdx <= this.numPages; pIdx++) {
const page = this.pdfDoc.getPage(pIdx - 1);
const { width, height } = page.getSize();
// Y axis goes bottom-to-top in PDF
const pgTexts = this.texts.filter((t) => t.pageIndex === pIdx);
const pgSticks = this.stickers.filter((s) => s.pageIndex === pIdx);
const pgShapes = this.shapes.filter((s) => s.pageIndex === pIdx);
if (this.activeShape && this.activeShape.pageIndex === pIdx) {
pgShapes.push(this.activeShape);
}
const renderedIds = new Set();
const pageObjects: { type: string; obj: any }[] = [];
this.renderOrder.forEach((id) => {
let obj: any = pgTexts.find((t) => t.id === id);
if (obj) {
pageObjects.push({ type: 'text', obj });
renderedIds.add(id);
return;
}
obj = pgSticks.find((s) => s.id === id);
if (obj) {
pageObjects.push({ type: 'sticker', obj });
renderedIds.add(id);
return;
}
obj = pgShapes.find((s) => s.id === id);
if (obj) {
pageObjects.push({ type: 'shape', obj });
renderedIds.add(id);
return;
}
});
pgTexts.forEach((t) => {
if (!renderedIds.has(t.id)) pageObjects.push({ type: 'text', obj: t });
});
pgSticks.forEach((s) => {
if (!renderedIds.has(s.id)) pageObjects.push({ type: 'sticker', obj: s });
});
pgShapes.forEach((s) => {
if (!renderedIds.has(s.id)) pageObjects.push({ type: 'shape', obj: s });
});
for (const item of pageObjects) {
if (item.type === 'text') {
const t = item.obj as TextLayer;
const cxC = document.createElement('canvas').getContext('2d')!;
const fontFn = (tx: any) =>
`${tx.italic ? 'italic ' : ''}${tx.bold ? 'bold ' : ''}${tx.fontSize}px ${tx.fontFamily}`;
const layout = computeTextLayout(cxC, t as any, fontFn);
const tw = layout.actualW;
const th = layout.actualH;
const cx = t.x + tw / 2;
const cy = t.y;
let lx = 0,
ly = 0;
if (t.tailActive && t.tailX !== undefined && t.tailY !== undefined) {
const dx = t.tailX - cx;
const dy = t.tailY - cy;
const cos = Math.cos(-t.rotation);
const sin = Math.sin(-t.rotation);
lx = dx * cos - dy * sin;
ly = dx * sin + dy * cos;
}
const pad = t.tailActive ? (t.strokeWidth || 0) + 12 : Math.max(t.strokeWidth || 0, 4);
const boundHalfW = Math.max(tw / 2 + pad, Math.abs(lx) + pad);
const boundHalfH = Math.max(th / 2 + pad, Math.abs(ly) + pad);
const cW = Math.ceil(boundHalfW * 2);
const cH = Math.ceil(boundHalfH * 2);
const scale = 2; // Better resolution
const c = document.createElement('canvas');
c.width = cW * scale;
c.height = cH * scale;
const ctx = c.getContext('2d')!;
ctx.scale(scale, scale);
ctx.translate(cW / 2, cH / 2);
if (t.bgColor || (t.strokeWidth && t.strokeWidth > 0)) {
buildBubblePath(ctx, tw, th, lx, ly, !!t.tailActive);
if (t.bgColor) {
ctx.fillStyle = t.bgColor;
ctx.fill();
}
if (t.strokeWidth && t.strokeWidth > 0) {
ctx.strokeStyle = t.strokeColor || '#000000';
ctx.lineWidth = t.strokeWidth;
ctx.lineJoin = 'round';
ctx.stroke();
}
}
ctx.font = fontFn({ ...t, fontSize: layout.fs });
ctx.fillStyle = t.color;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
let currentY = -th / 2 + layout.fs * 0.6;
for (const line of layout.lines) {
ctx.fillText(line, -tw / 2, currentY);
currentY += layout.lineHeight;
}
const dataUrl = c.toDataURL('image/png');
const imgBytes = Uint8Array.from(atob(dataUrl.split(',')[1]), (ch) => ch.charCodeAt(0));
const embedded = await this.pdfDoc.embedPng(imgBytes);
page.drawImage(embedded, {
x: cx - cW / 2,
y: height - cy - cH / 2,
width: cW,
height: cH,
rotate: degrees((-t.rotation * 180) / Math.PI),
});
} else if (item.type === 'sticker') {
const s = item.obj as StickerLayer;
const c = document.createElement('canvas');
c.width = s.img.width;
c.height = s.img.height;
c.getContext('2d')!.drawImage(s.img, 0, 0);
const dataUrl = c.toDataURL('image/png');
const imgBytes = Uint8Array.from(atob(dataUrl.split(',')[1]), (c) => c.charCodeAt(0));
const embedded = await this.pdfDoc.embedPng(imgBytes);
page.drawImage(embedded, {
x: s.x,
y: height - (s.y + s.h),
width: s.w,
height: s.h,
rotate: degrees((-s.rotation * 180) / Math.PI),
});
} else if (item.type === 'shape') {
const s = item.obj as ShapeLayer;
const box = getShapeBoundingBox(s as any);
const pad = (s.strokeWidth || 4) + 20;
const cW = Math.ceil(box.w + pad * 2);
const cH = Math.ceil(box.h + pad * 2);
const c = document.createElement('canvas');
c.width = cW;
c.height = cH;
const ctx = c.getContext('2d')!;
ctx.translate(cW / 2, cH / 2);
ctx.rotate(s.rotation || 0);
ctx.translate(-box.cx, -box.cy);
ctx.strokeStyle = s.color;
ctx.lineWidth = s.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
drawShapePath(ctx, s.type, s.arrowType || 'standard', s.color, s.x, s.y, s.w, s.h, s.bgColor, s.points);
const dataUrl = c.toDataURL('image/png');
const imgBytes = Uint8Array.from(atob(dataUrl.split(',')[1]), (ch) => ch.charCodeAt(0));
const embedded = await this.pdfDoc.embedPng(imgBytes);
page.drawImage(embedded, {
x: box.cx - cW / 2,
y: height - box.cy - cH / 2,
width: cW,
height: cH,
rotate: degrees((-(s.rotation || 0) * 180) / Math.PI),
});
}
}
}
}
private async _deletePage(idx0: number) {
// 0-indexed
if (this.numPages <= 1) return;
this.pdfDoc.removePage(idx0);
this.pdfBytes = await this.pdfDoc.save();
this.pdfBytesBase = new Uint8Array(this.pdfBytes!);
const pdfjsLib = (window as any).pdfjsLib;
this.pdfJsDoc = await pdfjsLib.getDocument({ data: this.pdfBytes! }).promise;
this.numPages = this.pdfJsDoc.numPages;
// Cleanup annotations for deleted page
this.texts = this.texts.filter((t) => t.pageIndex !== idx0 + 1);
this.stickers = this.stickers.filter((s) => s.pageIndex !== idx0 + 1);
this.shapes = this.shapes.filter((s) => s.pageIndex !== idx0 + 1);
// Shift indices
this.texts.forEach((t) => {
if (t.pageIndex && t.pageIndex > idx0 + 1) t.pageIndex--;
});
this.stickers.forEach((s) => {
if (s.pageIndex && s.pageIndex > idx0 + 1) s.pageIndex--;
});
this.shapes.forEach((s) => {
if (s.pageIndex && s.pageIndex > idx0 + 1) s.pageIndex--;
});
if (this.cp > this.numPages) this.cp = this.numPages;
await this._renderSidebar();
await this._renderPage();
}
private async _movePage(from0: number, to0: number) {
if (from0 === to0) return;
const [page] = await this.pdfDoc.copyPages(this.pdfDoc, [from0]);
this.pdfDoc.removePage(from0);
// if from < to, the removal shifts everything left, so 'to0' is inherently the right slot to insert.
let target = to0;
if (from0 < to0) target = to0 - 1;
// Wait, insertPage adds BEFORE the index. So if we want to be AT index 'to0', and we removed 'from0' < 'to0', old to0 is now to0-1.
// actually standard PDF-lib:
this.pdfDoc.insertPage(target, page);
this.pdfBytes = await this.pdfDoc.save();
this.pdfBytesBase = new Uint8Array(this.pdfBytes!);
const pdfjsLib = (window as any).pdfjsLib;
this.pdfJsDoc = await pdfjsLib.getDocument({ data: this.pdfBytes! }).promise;
// update annotation page indexes
const shift = (arr: any[]) => {
arr.forEach((a) => {
if (!a.pageIndex) return;
const p = a.pageIndex - 1;
if (p === from0) a.pageIndex = target + 1;
else if (from0 < to0 && p > from0 && p <= to0) a.pageIndex = p; // shifted down
else if (from0 > to0 && p >= to0 && p < from0) a.pageIndex = p + 2; // shifted up
});
};
shift(this.texts);
shift(this.stickers);
shift(this.shapes);
await this._renderSidebar();
await this._renderPage();
}
private async _insertBlankPage(index: number) {
if (!this.pdfBytes) return;
this.loadingOverlay.style.display = 'flex';
try {
this.pdfDoc.insertPage(index, [595.28, 841.89]); // A4
this.pdfBytes = await this.pdfDoc.save();
this.pdfBytesBase = new Uint8Array(this.pdfBytes!);
const pdfjsLib = (window as any).pdfjsLib;
this.pdfJsDoc = await pdfjsLib.getDocument({ data: this.pdfBytes! }).promise;
this.numPages = this.pdfJsDoc.numPages;
const shift = (arr: any[]) => {
arr.forEach((a) => {
if (a.pageIndex > index) a.pageIndex++;
});
};
shift(this.texts);
shift(this.stickers);
shift(this.shapes);
if (this.cp > index) this.cp++;
await this._renderSidebar();
await this._renderPage();
} catch (err) {
console.error(err);
}
this.loadingOverlay.style.display = 'none';
}
private async _gotoPage(p: number) {
if (p >= 1 && p <= this.numPages) {
this.cp = p;
await this._renderSidebar();
await this._renderPage();
}
}
private _updateZoomSlider() {
if (this.zoomSliderRange && this.zoomSliderLabel) {
this.zoomSliderRange.value = String(this.zoom);
this.zoomSliderLabel.textContent = `${Math.round(this.zoom * 100)}%`;
}
}
private _zoom(delta: number) {
this.zoom = Math.max(0.3, Math.min(4, this.zoom + delta));
this._updateZoomSlider();
if (this.pdfDoc) {
this._renderPage();
}
}
// ─── Rendering ───────────────────────────────────────────────────────────────
private async _renderSidebar() {
this.sidebar.innerHTML = '';
// Draw thumbnails
for (let i = 1; i <= this.numPages; i++) {
const wrap = document.createElement('div');
wrap.className = 'pe-thumb-wrap' + (this.cp === i ? ' active' : '');
wrap.draggable = true;
wrap.ondragstart = (e) => {
e.dataTransfer!.setData('text/plain', (i - 1).toString());
};
wrap.ondragover = (e) => {
e.preventDefault();
wrap.classList.add('drag-over');
};
wrap.ondragleave = (e) => {
wrap.classList.remove('drag-over');
};
wrap.ondrop = (e) => {
wrap.classList.remove('drag-over');
const from = parseInt(e.dataTransfer!.getData('text/plain'));
this._movePage(from, i - 1);
};
wrap.onclick = () => this._gotoPage(i);
const num = document.createElement('div');
num.className = 'pe-thumb-num';
num.textContent = i + '';
const del = document.createElement('div');
del.className = 'pe-thumb-del';
del.innerHTML = '×';
del.onclick = (e) => {
e.stopPropagation();
this._deletePage(i - 1);
};
const addTop = document.createElement('div');
addTop.className = 'pe-thumb-add-top';
addTop.textContent = '+';
addTop.onclick = (e) => {
e.stopPropagation();
this._insertBlankPage(i - 1);
};
const addBottom = document.createElement('div');
addBottom.className = 'pe-thumb-add-bottom';
addBottom.textContent = '+';
addBottom.onclick = (e) => {
e.stopPropagation();
this._insertBlankPage(i);
};
const cv = document.createElement('canvas');
cv.className = 'pe-thumb-canvas';
wrap.appendChild(cv);
wrap.appendChild(num);
wrap.appendChild(del);
wrap.appendChild(addTop);
wrap.appendChild(addBottom);
this.sidebar.appendChild(wrap);
// Render thumbnail
const page = await this.pdfJsDoc.getPage(i);
const viewport = page.getViewport({ scale: 180 / page.getViewport({ scale: 1 }).width }); // scale to width 180
cv.width = viewport.width;
cv.height = viewport.height;
await page.render({ canvasContext: cv.getContext('2d')!, viewport }).promise;
}
}
private async _renderPage() {
if (!this.pdfJsDoc) return;
this._updateOpts();
const page = await this.pdfJsDoc.getPage(this.cp);
const viewport = page.getViewport({ scale: this.zoom });
this.canvasBg.width = viewport.width;
this.canvasBg.height = viewport.height;
this.canvasFg.width = viewport.width;
this.canvasFg.height = viewport.height;
this.wrap.style.width = viewport.width + 'px';
this.wrap.style.height = viewport.height + 'px';
await page.render({ canvasContext: this.ctxBg, viewport }).promise;
this._drawFg();
}
private _textFontFn = (t: TextLayer) =>
`${t.italic ? 'italic ' : ''}${t.bold ? 'bold ' : ''}${t.fontSize * this.zoom}px ${t.fontFamily}`;
private _unzoomedFontFn = (t: TextLayer) =>
`${t.italic ? 'italic ' : ''}${t.bold ? 'bold ' : ''}${t.fontSize}px ${t.fontFamily}`;
private _drawFg() {
this.ctxFg.clearRect(0, 0, this.canvasFg.width, this.canvasFg.height);
const pgTexts = this.texts.filter((t) => t.pageIndex === this.cp);
const pgStickers = this.stickers.filter((s) => s.pageIndex === this.cp);
const pgShapes = this.shapes.filter((s) => s.pageIndex === this.cp);
const renderedIds = new Set();
const pageObjects: { type: string; obj: any }[] = [];
this.renderOrder.forEach((id) => {
let obj: any = pgTexts.find((t) => t.id === id);
if (obj) {
pageObjects.push({ type: 'text', obj });
renderedIds.add(id);
return;
}
obj = pgStickers.find((s) => s.id === id);
if (obj) {
pageObjects.push({ type: 'sticker', obj });
renderedIds.add(id);
return;
}
obj = pgShapes.find((s) => s.id === id);
if (obj) {
pageObjects.push({ type: 'shape', obj });
renderedIds.add(id);
return;
}
});
pgTexts.forEach((t) => {
if (!renderedIds.has(t.id)) {
pageObjects.push({ type: 'text', obj: t });
this.renderOrder.push(t.id);
}
});
pgStickers.forEach((s) => {
if (!renderedIds.has(s.id)) {
pageObjects.push({ type: 'sticker', obj: s });
this.renderOrder.push(s.id);
}
});
pgShapes.forEach((s) => {
if (!renderedIds.has(s.id)) {
pageObjects.push({ type: 'shape', obj: s });
this.renderOrder.push(s.id);
}
});
for (const item of pageObjects) {
if (item.type === 'text') {
const t = item.obj as TextLayer;
const layout = computeTextLayout(this.ctxFg, t, (layer: any) => this._unzoomedFontFn(layer as TextLayer));
const tw = layout.actualW * this.zoom;
const th = layout.actualH * this.zoom;
const cx = t.x * this.zoom + tw / 2;
const cy = t.y * this.zoom;
let lx = 0,
ly = 0;
if (t.tailActive && t.tailX !== undefined && t.tailY !== undefined) {
const dx = t.tailX * this.zoom - cx;
const dy = t.tailY * this.zoom - cy;
const cos = Math.cos(-t.rotation);
const sin = Math.sin(-t.rotation);
lx = dx * cos - dy * sin;
ly = dx * sin + dy * cos;
}
this.ctxFg.save();
this.ctxFg.translate(cx, cy);
this.ctxFg.rotate(t.rotation);
this.ctxFg.font = this._textFontFn({ ...t, fontSize: layout.fs * this.zoom } as TextLayer);
if (t.bgColor || t.strokeWidth !== undefined) {
buildBubblePath(this.ctxFg, tw, th, lx, ly, !!t.tailActive);
if (t.bgColor) {
this.ctxFg.fillStyle = t.bgColor;
this.ctxFg.fill();
}
if (t.strokeWidth && t.strokeWidth > 0) {
this.ctxFg.setLineDash([]);
this.ctxFg.strokeStyle = t.strokeColor || '#000000';
this.ctxFg.lineWidth = t.strokeWidth * this.zoom;
this.ctxFg.lineJoin = 'round';
this.ctxFg.stroke();
} else if (t.strokeWidth === 0 && !this._isExporting) {
this.ctxFg.setLineDash([5, 5]);
this.ctxFg.strokeStyle = '#aaa';
this.ctxFg.lineWidth = 1;
this.ctxFg.lineJoin = 'round';
this.ctxFg.stroke();
this.ctxFg.setLineDash([]); // reset immediately
}
}
this.ctxFg.fillStyle = t.color;
this.ctxFg.textAlign = 'left';
this.ctxFg.textBaseline = 'middle';
const totalTextH = layout.lines.length * layout.lineHeight * this.zoom;
const startY = -th / 2 + (th - totalTextH) / 2 + (layout.lineHeight * this.zoom) / 2;
for (let i = 0; i < layout.lines.length; i++) {
this.ctxFg.fillText(layout.lines[i], -tw / 2, startY + i * layout.lineHeight * this.zoom);
}
this.ctxFg.restore();
} else if (item.type === 'sticker') {
const s = item.obj as StickerLayer;
this.ctxFg.save();
const cx = s.x + s.w / 2;
const cy = s.y + s.h / 2;
this.ctxFg.translate(cx * this.zoom, cy * this.zoom);
this.ctxFg.rotate(s.rotation);
this.ctxFg.drawImage(s.img, (-s.w / 2) * this.zoom, (-s.h / 2) * this.zoom, s.w * this.zoom, s.h * this.zoom);
this.ctxFg.restore();
} else if (item.type === 'shape') {
const sh = item.obj as ShapeLayer;
this.ctxFg.save();
const box = getShapeBoundingBox(sh as any);
this.ctxFg.translate(box.cx * this.zoom, box.cy * this.zoom);
this.ctxFg.rotate(sh.rotation || 0);
this.ctxFg.translate(-box.cx * this.zoom, -box.cy * this.zoom);
this.ctxFg.strokeStyle = sh.color;
this.ctxFg.lineWidth = sh.strokeWidth * this.zoom;
this.ctxFg.lineCap = 'round';
this.ctxFg.lineJoin = 'round';
const scaledPoints = sh.points ? sh.points.map((p) => ({ x: p.x * this.zoom, y: p.y * this.zoom })) : undefined;
drawShapePath(
this.ctxFg,
sh.type,
sh.arrowType || 'standard',
sh.color,
sh.x * this.zoom,
sh.y * this.zoom,
sh.w * this.zoom,
sh.h * this.zoom,
sh.bgColor,
scaledPoints
);
this.ctxFg.restore();
}
}
if (this.activeShape) {
const sh = this.activeShape;
this.ctxFg.save();
const box = getShapeBoundingBox(sh as any);
this.ctxFg.translate(box.cx * this.zoom, box.cy * this.zoom);
this.ctxFg.rotate(sh.rotation || 0);
this.ctxFg.translate(-box.cx * this.zoom, -box.cy * this.zoom);
this.ctxFg.strokeStyle = sh.color;
this.ctxFg.lineWidth = sh.strokeWidth * this.zoom;
this.ctxFg.lineCap = 'round';
this.ctxFg.lineJoin = 'round';
const scaledPoints = sh.points ? sh.points.map((p) => ({ x: p.x * this.zoom, y: p.y * this.zoom })) : undefined;
drawShapePath(
this.ctxFg,
sh.type,
sh.arrowType || 'standard',
sh.color,
sh.x * this.zoom,
sh.y * this.zoom,
sh.w * this.zoom,
sh.h * this.zoom,
sh.bgColor,
scaledPoints
);
this.ctxFg.restore();
}
// Handles
if (this.activeId && this.tool === 'pan') {
const txt = pgTexts.find((x) => x.id === this.activeId);
if (txt) {
const h = getTextHandles(txt as any, this.zoom, this.ctxFg, this._textFontFn as any);
this._drawHandles(h);
}
const st = pgStickers.find((x) => x.id === this.activeId);
if (st) {
const h = getStickerHandles(st as any, this.zoom);
this._drawHandles(h);
}
const sh = pgShapes.find((x) => x.id === this.activeId);
if (sh) {
const h = getShapeHandles(sh as any, this.zoom);
this._drawHandles(h);
}
}
}
private _drawHandles(h: any) {
const s = HANDLE_SIZE;
if (h.rot) {
this.ctxFg.beginPath();
this.ctxFg.arc(h.rot.x * this.zoom, h.rot.y * this.zoom, 6, 0, Math.PI * 2);
this.ctxFg.fillStyle = '#0af';
this.ctxFg.fill();
this.ctxFg.strokeStyle = '#fff';
this.ctxFg.stroke();
}
if (h.resize) {
this.ctxFg.fillStyle = '#fff';
this.ctxFg.strokeStyle = '#333';
this.ctxFg.lineWidth = 1;
this.ctxFg.fillRect(h.resize.x * this.zoom - s / 2, h.resize.y * this.zoom - s / 2, s, s);
this.ctxFg.strokeRect(h.resize.x * this.zoom - s / 2, h.resize.y * this.zoom - s / 2, s, s);
}
if (h.del) {
drawDeleteHandle(this.ctxFg, h.del.x * this.zoom, h.del.y * this.zoom, 10);
}
if (h.tail) {
this.ctxFg.fillStyle = '#ffaa00';
this.ctxFg.strokeStyle = '#fff';
this.ctxFg.lineWidth = 1;
this.ctxFg.beginPath();
this.ctxFg.arc(h.tail.x * this.zoom, h.tail.y * this.zoom, 4, 0, Math.PI * 2);
this.ctxFg.fill();
this.ctxFg.stroke();
}
}
// ─── Events ──────────────────────────────────────────────────────────────────
private _attachEvents() {
const cw = this.canvasFg;
let isDragging = false;
let startPanX = 0,
startPanY = 0;
cw.onmousedown = (e) => {
this.container.focus();
if (this.textInput && this.textCommitFn) {
this.textCommitFn();
return;
}
const rect = cw.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.zoom;
const y = (e.clientY - rect.top) / this.zoom;
if (this.tool === 'pan') {
const area = this.container.querySelector('.pe-canvas-area')!;
// hit detect
const pgTexts = this.texts.filter((t) => t.pageIndex === this.cp);
const pgStickers = this.stickers.filter((s) => s.pageIndex === this.cp);
const pgShapes = this.shapes.filter((s) => s.pageIndex === this.cp);
let hit = null;
let startedAction = false;
if (this.activeId) {
// check handles first
const t = pgTexts.find((x) => x.id === this.activeId);
if (t) {
const h = getTextHandles(t as any, this.zoom, this.ctxFg, this._unzoomedFontFn as any);
if (Math.abs(x - h.rot.x) * this.zoom < HANDLE_SIZE && Math.abs(y - h.rot.y) * this.zoom < HANDLE_SIZE) {
this._pushUndo();
this.ctxFg.font = this._textFontFn(t as any);
const layout = computeTextLayout(this.ctxFg, t as any, this._unzoomedFontFn as any);
const cx = t.x + layout.actualW / 2;
const startAngle = Math.atan2(y - t.y, x - cx);
this.dragState = {
type: 'rotT',
obj: t,
cx,
cy: t.y,
startAngle,
startRotation: t.rotation || 0,
};
return;
}
if (
Math.abs(x - h.resize.x) * this.zoom < HANDLE_SIZE &&
Math.abs(y - h.resize.y) * this.zoom < HANDLE_SIZE
) {
this._pushUndo();
this.dragState = {
type: 'resizeT',
obj: t,
startX: x,
startY: y,
startW: t.w || 200,
startH: t.h || t.fontSize * 1.5,
};
return;
}
if (Math.abs(x - h.del.x) * this.zoom < HANDLE_SIZE && Math.abs(y - h.del.y) * this.zoom < HANDLE_SIZE) {
this._pushUndo();
this.texts = this.texts.filter((tx) => tx.id !== this.activeId);
this.activeId = null;
this._drawFg();
return;
}
if (
h.tail &&
Math.abs(x - h.tail.x) * this.zoom < HANDLE_SIZE &&
Math.abs(y - h.tail.y) * this.zoom < HANDLE_SIZE
) {
this._pushUndo();
this.dragState = { type: 'dtail', obj: t, lastX: x, lastY: y };
return;
}
}
const s = pgStickers.find((x) => x.id === this.activeId);
if (s) {
const h = getStickerHandles(s as any, this.zoom);
if (Math.abs(x - h.del.x) * this.zoom < HANDLE_SIZE && Math.abs(y - h.del.y) * this.zoom < HANDLE_SIZE) {
this._pushUndo();
this.stickers = this.stickers.filter((sx) => sx.id !== this.activeId);
this.activeId = null;
this._drawFg();
return;
}
if (Math.abs(x - h.rot.x) * this.zoom < HANDLE_SIZE && Math.abs(y - h.rot.y) * this.zoom < HANDLE_SIZE) {
this._pushUndo();
this.dragState = { type: 'rotS', obj: s, cx: s.x + s.w / 2, cy: s.y + s.h / 2 };
return;
}
if (
Math.abs(x - h.resize.x) * this.zoom < HANDLE_SIZE &&
Math.abs(y - h.resize.y) * this.zoom < HANDLE_SIZE
) {
this._pushUndo();
this.dragState = { type: 'resizeS', obj: s, startX: x, startY: y, origW: s.w, origH: s.h };
return;
}
}
const shp = pgShapes.find((x) => x.id === this.activeId);
if (shp) {
const h = getShapeHandles(shp as any, this.zoom);
if (Math.abs(x - h.del.x) * this.zoom < HANDLE_SIZE && Math.abs(y - h.del.y) * this.zoom < HANDLE_SIZE) {
this._pushUndo();
this.shapes = this.shapes.filter((sx) => sx.id !== this.activeId);
this.activeId = null;
this._drawFg();
return;
}
if (Math.abs(x - h.rot.x) * this.zoom < HANDLE_SIZE && Math.abs(y - h.rot.y) * this.zoom < HANDLE_SIZE) {
this._pushUndo();
const box = getShapeBoundingBox(shp as any);
this.dragState = { type: 'rotShp', obj: shp, cx: box.cx, cy: box.cy };
return;
}
if (
Math.abs(x - h.resize.x) * this.zoom < HANDLE_SIZE &&
Math.abs(y - h.resize.y) * this.zoom < HANDLE_SIZE
) {
this._pushUndo();
const box = getShapeBoundingBox(shp as any);
this.dragState = {
type: 'resizeShp',
obj: shp,
startX: x,
startY: y,
origX: shp.x,
origY: shp.y,
origW: shp.w,
origH: shp.h,
boxCX: box.cx,
boxCY: box.cy,
boxW: box.w,
boxH: box.h,
origPoints: shp.points ? shp.points.map((p: any) => ({ ...p })) : [],
};
return;
}
}
}
for (let i = pgTexts.length - 1; i >= 0; i--) {
if (hitText({ x, y }, pgTexts[i] as any, this.ctxFg, this._unzoomedFontFn as any)) {
hit = pgTexts[i];
break;
}
}
if (!hit) {
for (let i = pgStickers.length - 1; i >= 0; i--) {
if (hitSticker({ x, y }, pgStickers[i] as any)) {
hit = pgStickers[i];
break;
}
}
}
if (!hit) {
for (let i = pgShapes.length - 1; i >= 0; i--) {
if (hitShape({ x, y }, pgShapes[i] as any)) {
hit = pgShapes[i];
break;
}
}
}
if (hit) {
this._pushUndo();
this.activeId = hit.id;
this.dragState = { type: 'move', obj: hit, ox: x - hit.x, oy: y - hit.y };
} else {
this.activeId = null;
isDragging = true;
startPanX = e.clientX + area.scrollLeft;
startPanY = e.clientY + area.scrollTop;
(area as HTMLElement).style.cursor = 'grabbing';
}
this._updateOpts();
this._drawFg();
} else if (this.tool === 'text') {
let boxW = Math.max(200, this._fontSize * 5);
let boxH = Math.max(50, this._fontSize * 1.5);
const initialRotation = this._tempRot;
this._tempRot = 0; // reset immediately
const inp = document.createElement('textarea');
inp.className = 'pe-text-input';
inp.placeholder = 'Type text... (Shift+Enter to newline)';
inp.style.position = 'absolute';
inp.style.left = x * this.zoom + 'px';
inp.style.top = y * this.zoom + 'px';
inp.style.color = this.color;
inp.style.fontSize = this._fontSize * this.zoom + 'px';
inp.style.fontFamily = this._fontFamily;
inp.style.fontWeight = 'normal';
inp.style.fontStyle = 'normal';
inp.style.background = 'transparent';
inp.style.border = '1px dashed #0af';
inp.style.outline = 'none';
inp.style.resize = 'none';
inp.style.overflow = 'auto'; // allow inner scroll
inp.style.whiteSpace = 'pre'; // prevent auto layout wrap while typing
inp.style.lineHeight = '1.2';
inp.style.padding = '4px';
inp.style.transform = `translate(0, -50%) rotate(${initialRotation}rad)`; // aligned origin
inp.style.transformOrigin = '50% 50%';
inp.dataset.rot = String(initialRotation);
inp.dataset.explicitw = 'false';
inp.style.pointerEvents = 'auto'; // ensure click works
inp.style.width = boxW * this.zoom + 'px';
inp.style.height = boxH * this.zoom + 'px';
this.wrap.appendChild(inp);
this.textInput = inp;
this.textCommitFn = () => {
if (!this.textInput) return;
const val = inp.value.trim();
if (val) {
const newId = inp.dataset.editid || 't_' + Date.now();
const explicitW = inp.dataset.explicitw === 'true';
const uiA = parseFloat(inp.style.width) / this.zoom || 200;
const expectedBoxW = explicitW ? uiA : undefined;
const t: TextLayer = {
id: newId,
pageIndex: this.cp,
text: val,
x,
y,
w: undefined,
h: undefined,
color: this.color,
bgColor: this.fillColor,
fontSize: this._fontSize,
fontFamily: this._fontFamily,
bold: this._textBold,
italic: this._textItalic,
rotation: Number(inp.dataset.rot) || 0,
strokeColor: this._textStrokeColor,
strokeWidth: this._textStrokeWidth,
tailActive: this._textTailActive,
};
const layoutB = computeTextLayout(this.ctxFg, t, this._unzoomedFontFn.bind(this) as any);
const B = layoutB.actualW;
t.w = explicitW ? expectedBoxW : Math.min(B, uiA);
const layoutFinal = computeTextLayout(this.ctxFg, t, this._unzoomedFontFn.bind(this) as any);
t.h = layoutFinal.actualH;
t.fontSize = layoutFinal.fs;
if (t.tailActive && (t.tailX === undefined || t.tailY === undefined)) {
t.tailX = t.x;
t.tailY = t.y + t.fontSize * 1.5;
}
this._pushUndo();
this.texts.push(t);
if (!this.renderOrder.includes(newId)) this.renderOrder.push(newId);
this.activeId = newId;
this._setTool('pan', true);
}
if (this.textCancelFn) this.textCancelFn();
if (val) {
this._updateOpts();
this._drawFg();
}
};
this.textCancelFn = () => {
if (this.textInput) {
this.textInput.remove();
this.textInput = null;
this.textCommitFn = null;
this.textCancelFn = null;
}
};
inp.onmousedown = (ev) => ev.stopPropagation(); // prevent re-triggering canvas mousedown
inp.onkeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.textCommitFn!();
}
if (e.key === 'Escape') {
this.textCancelFn!();
}
};
setTimeout(() => inp.focus(), 50);
} else if (this.tool === 'pencil') {
this._pushUndo();
this.activeShape = {
id: 'shp_' + Date.now(),
pageIndex: this.cp,
type: this._penMode,
color: this.color,
bgColor: this.fillColor,
strokeWidth: this._penSize,
points: [{ x, y }],
rotation: 0,
x,
y,
w: x,
h: y,
arrowType: this._arrowType,
};
}
};
window.addEventListener('mousemove', (e) => {
if (isDragging && this.tool === 'pan') {
const area = this.container.querySelector('.pe-canvas-area')!;
area.scrollLeft = startPanX - e.clientX;
area.scrollTop = startPanY - e.clientY;
}
if (this.dragState) {
const rect = cw.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.zoom;
const y = (e.clientY - rect.top) / this.zoom;
const st = this.dragState;
if (st.type === 'move') {
const pgW = this.canvasFg.width / this.zoom;
const pgH = this.canvasFg.height / this.zoom;
let bx: { cx: number; cy: number; w: number; h: number } | null = null;
if ((st.obj as any).text !== undefined) {
const layout = computeTextLayout(this.ctxFg, st.obj as TextLayer, (t: any) => this._unzoomedFontFn(t));
bx = {
cx: st.obj.x + layout.actualW / 2,
cy: st.obj.y,
w: layout.actualW,
h: layout.actualH,
};
} else if ((st.obj as any).img !== undefined) {
bx = { cx: st.obj.x + st.obj.w / 2, cy: st.obj.y + st.obj.h / 2, w: st.obj.w, h: st.obj.h };
} else if ((st.obj as any).type !== undefined) {
bx = getShapeBoundingBox(st.obj as any);
}
let dx = 0;
let dy = 0;
if (bx) {
const rightMax = Math.max(0, pgW - bx.w);
const bottomMax = Math.max(0, pgH - bx.h);
const targetDx = x - st.ox - st.obj.x;
const targetDy = y - st.oy - st.obj.y;
const targetLeft = bx.cx - bx.w / 2 + targetDx;
const targetTop = bx.cy - bx.h / 2 + targetDy;
const clampedLeft = Math.max(0, Math.min(targetLeft, rightMax));
const clampedTop = Math.max(0, Math.min(targetTop, bottomMax));
dx = clampedLeft - (bx.cx - bx.w / 2);
dy = clampedTop - (bx.cy - bx.h / 2);
} else {
dx = x - st.ox - st.obj.x;
dy = y - st.oy - st.obj.y;
}
const el = document.elementFromPoint(e.clientX, e.clientY);
const thumbWrap = el?.closest('.pe-thumb-wrap') as HTMLElement;
if (thumbWrap && thumbWrap.dataset.pageIndex) {
const hIdx = parseInt(thumbWrap.dataset.pageIndex, 10);
if (hIdx !== this.cp) {
this.cp = hIdx;
st.obj.pageIndex = hIdx;
this._renderSidebar();
this._renderPage();
}
}
if (st.obj.type === 'pencil' && st.obj.points) {
st.obj.points.forEach((p: any) => {
p.x += dx;
p.y += dy;
});
} else if (st.obj.type && st.obj.type !== 'pencil') {
st.obj.w += dx;
st.obj.h += dy;
}
st.obj.x += dx;
st.obj.y += dy;
if ((st.obj as any).text !== undefined && st.obj.tailActive) {
const t = st.obj as TextLayer;
if (t.tailX !== undefined) t.tailX += dx;
if (t.tailY !== undefined) t.tailY += dy;
}
} else if (st.type === 'dtail') {
const dx = x - st.lastX;
const dy = y - st.lastY;
const t = st.obj as TextLayer;
if (t.tailX !== undefined) t.tailX += dx;
if (t.tailY !== undefined) t.tailY += dy;
st.lastX = x;
st.lastY = y;
} else if (st.type.startsWith('rot')) {
st.obj.rotation = Math.atan2(y - st.cy, x - st.cx) + Math.PI / 2;
} else if (st.type === 'resizeT') {
const dx = x - st.startX;
const dy = y - st.startY;
const rot = st.obj.rotation || 0;
const cos = Math.cos(-rot);
const sin = Math.sin(-rot);
const unDx = dx * cos - dy * sin;
const unDy = dx * sin + dy * cos;
st.obj.w = Math.max(60, st.startW + unDx);
st.obj.h = Math.max(20, st.startH + unDy * 2);
} else if (st.type === 'resizeS') {
const dx = x - st.startX;
const dy = y - st.startY;
let targetW = st.origW + dx;
let targetH = st.origH + dy;
if (e.shiftKey) {
const ratio = st.origW / st.origH;
if (Math.abs(targetW) / Math.abs(targetH) > ratio) {
targetW = targetH * ratio;
} else {
targetH = targetW / ratio;
}
}
st.obj.w = Math.max(20, targetW);
st.obj.h = Math.max(20, targetH);
} else if (st.type === 'resizeShp') {
const dx = x - st.startX;
const dy = y - st.startY;
const rot = st.obj.rotation || 0;
const cos = Math.cos(-rot);
const sin = Math.sin(-rot);
const unDx = dx * cos - dy * sin;
const unDy = dx * sin + dy * cos;
const scaleX = Math.max(0.1, st.boxW + unDx * 2) / Math.max(0.1, st.boxW);
const scaleY = Math.max(0.1, st.boxH + unDy * 2) / Math.max(0.1, st.boxH);
if (st.obj.type === 'pencil' && st.obj.points) {
st.obj.points.forEach((p: any, i: number) => {
p.x = st.boxCX + (st.origPoints[i].x - st.boxCX) * scaleX;
p.y = st.boxCY + (st.origPoints[i].y - st.boxCY) * scaleY;
});
st.startX = x;
st.startY = y;
st.boxW = Math.max(1, st.boxW + unDx * 2);
st.boxH = Math.max(1, st.boxH + unDy * 2);
st.origPoints = st.obj.points.map((p: any) => ({ ...p }));
} else if (st.obj.type === 'circle' || st.obj.type === 'star') {
const radScale = Math.max(scaleX, scaleY);
st.obj.w = st.obj.x + (st.origW - st.obj.x) * radScale;
st.obj.h = st.obj.y + (st.origH - st.obj.y) * radScale;
} else {
st.obj.x = st.boxCX + (st.origX - st.boxCX) * scaleX;
st.obj.y = st.boxCY + (st.origY - st.boxCY) * scaleY;
st.obj.w = st.boxCX + (st.origW - st.boxCX) * scaleX;
st.obj.h = st.boxCY + (st.origH - st.boxCY) * scaleY;
}
}
this._drawFg();
}
if (this.activeShape) {
const rect = cw.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.zoom;
const y = (e.clientY - rect.top) / this.zoom;
this.activeShape.w = x;
this.activeShape.h = y;
if (this.activeShape.type === 'pencil' && this.activeShape.points) {
this.activeShape.points.push({ x, y });
}
this._drawFg();
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
const area = this.container.querySelector('.pe-canvas-area') as HTMLElement;
area.style.cursor = 'grab';
}
let dragged = false;
if (this.dragState) dragged = true;
if (this.dragState && this.dragState.type === 'resizeT') {
const tObj = this.dragState.obj as any;
const layout = computeTextLayout(this.ctxFg, tObj, this._unzoomedFontFn.bind(this) as any);
tObj.fontSize = layout.fs;
this._fontSize = layout.fs;
}
this.dragState = null;
let shapeCommitted = false;
if (this.activeShape) {
this.shapes.push(this.activeShape);
if (!this.renderOrder.includes(this.activeShape.id)) this.renderOrder.push(this.activeShape.id);
const newActive = this.activeShape.id;
this.activeShape = null;
this.activeId = newActive;
shapeCommitted = true;
this._setTool('pan', true);
}
const movedOrDrawn = dragged || shapeCommitted;
if (movedOrDrawn) {
this._drawFg();
}
});
cw.addEventListener('dblclick', (e) => {
if (this.tool !== 'pan' && this.tool !== 'select') return;
const rect = cw.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.zoom;
const y = (e.clientY - rect.top) / this.zoom;
const pgTexts = this.texts.filter((t) => t.pageIndex === this.cp);
for (let i = pgTexts.length - 1; i >= 0; i--) {
const t = pgTexts[i];
if (hitText({ x, y }, t as any, this.ctxFg, this._textFontFn as any)) {
// Re-edit text
this._pushUndo();
this.texts = this.texts.filter((tx) => tx.id !== t.id); // Remove first
this.activeId = null;
this.color = t.color;
const layout = computeTextLayout(this.ctxFg, t as any, this._unzoomedFontFn.bind(this) as any);
this._fontSize = layout.fs;
this.fillColor = t.bgColor;
this._textStrokeColor = t.strokeColor || '#000000';
this._textStrokeWidth = t.strokeWidth || 0;
this._textTailActive = !!t.tailActive;
this._textBold = !!t.bold;
this._textItalic = !!t.italic;
this._fontFamily = t.fontFamily || 'sans-serif';
this._updateOpts();
// Simulate clicking to insert text
this._tempRot = t.rotation || 0;
this._setTool('text');
const mdev = new MouseEvent('mousedown', {
clientX: rect.left + t.x * this.zoom,
clientY: rect.top + t.y * this.zoom,
bubbles: true,
});
cw.dispatchEvent(mdev);
setTimeout(() => {
if (this.textInput) {
this.textInput.value = t.text;
this.textInput.dataset.editid = t.id;
this.textInput.dataset.rot = String(t.rotation || 0);
this.textInput.style.transform = `translate(0, -50%) rotate(${t.rotation || 0}rad)`;
if (t.w) {
this.textInput.style.width = String(t.w * this.zoom) + 'px';
this.textInput.dataset.explicitw = 'true';
}
if (t.h) this.textInput.style.height = String(t.h * this.zoom) + 'px';
this.textInput.dispatchEvent(new Event('input'));
}
}, 0);
return;
}
}
});
this.container.addEventListener('keydown', (e) => {
// Undo / Redo Shortcuts
if ((e.ctrlKey || e.metaKey) && typeof e.key === 'string' && e.key.toLowerCase() === 'z') {
if (e.shiftKey) {
e.preventDefault();
this.redo();
} else {
e.preventDefault();
this.undo();
}
return;
}
if ((e.ctrlKey || e.metaKey) && typeof e.key === 'string' && e.key.toLowerCase() === 'y') {
e.preventDefault();
this.redo();
return;
}
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c' || e.key === 'C') {
if (this.textInput || this.activeId === null) return;
this._copyInternalClipboard();
} else if (e.key === 'x' || e.key === 'X') {
if (this.textInput || this.activeId === null) return;
this._copyInternalClipboard();
this._pushUndo();
this.texts = this.texts.filter((t) => t.id !== this.activeId);
this.stickers = this.stickers.filter((s) => s.id !== this.activeId);
this.shapes = this.shapes.filter((s) => s.id !== this.activeId);
this.activeId = null;
this._updateOpts();
this._drawFg();
} else if (e.key === 'v' || e.key === 'V') {
if (this.textInput) return;
let _w: any = window;
try {
_w = window.top || window;
} catch (err) {}
const globalTime = _w.__lupine_clipboard_time || 0;
const globalImgTime = _w.__lupine_clipboard_img_time || 0;
if (globalTime > 0 || globalImgTime > 0 || this.internalClipboard) {
e.preventDefault();
this._pasteClipboard();
}
}
}
// Keyboard Delete
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.textInput || this.activeId === null) return;
this._pushUndo();
this.texts = this.texts.filter((t) => t.id !== this.activeId);
this.stickers = this.stickers.filter((s) => s.id !== this.activeId);
this.shapes = this.shapes.filter((s) => s.id !== this.activeId);
this.activeId = null;
this._updateOpts();
this._drawFg();
}
});
this.container.addEventListener('paste', (e) => {
// Do not intercept if user is typing in an input
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
// Prevent custom internal payloads (which Ctrl+V handles) from triggering external Image parsing natively
if (e.clipboardData?.types.includes('text/html')) {
const payloadFlag = e.clipboardData.getData('text/html');
if (payloadFlag && payloadFlag.includes('lupine-editor')) return;
}
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
const img = new Image();
img.onload = () => {
this._pushUndo();
const maxDim = 200;
let nw = img.naturalWidth;
let nh = img.naturalHeight;
if (nw > maxDim || nh > maxDim) {
const ratio = Math.min(maxDim / nw, maxDim / nh);
nw *= ratio;
nh *= ratio;
}
const newSticker: StickerLayer = {
id: 's_' + Date.now(),
pageIndex: this.cp,
img,
x: 50 / this.zoom,
y: 50 / this.zoom,
w: nw / this.zoom,
h: nh / this.zoom,
rotation: 0,
};
this.stickers.push(newSticker);
if (!this.renderOrder.includes(newSticker.id)) this.renderOrder.push(newSticker.id);
this.activeId = newSticker.id;
this._setTool('pan', true);
this._drawFg();
};
img.src = URL.createObjectURL(file);
}
return;
}
}
});
}
private _copyInternalClipboard() {
if (!this.activeId) return;
const txt = this.texts.find((t) => t.id === this.activeId);
if (txt) {
const layout = computeTextLayout(this.ctxFg, txt as any, this._unzoomedFontFn.bind(this) as any);
this.internalClipboard = { type: 'text', data: { ...txt, w: layout.actualW, h: layout.actualH } };
} else {
const stk = this.stickers.find((s) => s.id === this.activeId);
if (stk) {
this.internalClipboard = { type: 'sticker', data: { ...stk } };
} else {
const shp = this.shapes.find((s) => s.id === this.activeId);
if (shp) {
this.internalClipboard = {
type: 'shape',
data: {
...shp,
points: shp.points ? shp.points.map((p: any) => ({ ...p })) : undefined,
},
};
}
}
}
if (this.internalClipboard) {
this.internalClipboardTime = Date.now();
try {
const _w: any = window.top || window;
_w.__lupine_clipboard = this.internalClipboard;
_w.__lupine_clipboard_time = this.internalClipboardTime;
} catch (err) {}
exportToSystemClipboard(this.internalClipboard, (ctx, shifted) => {
if (this.internalClipboard!.type === 'text') {
renderTextLayer(ctx, shifted, this._unzoomedFontFn.bind(this) as any, 1, true);
} else if (this.internalClipboard!.type === 'shape') {
renderShapeLayer(ctx, shifted, 1);
} else if (this.internalClipboard!.type === 'sticker') {
renderStickerLayer(ctx, shifted, 1);
}
});
}
}
private _pasteClipboard() {
let _w: any = window;
try {
_w = window.top || window;
} catch (err) {}
const globalTime = _w.__lupine_clipboard_time || 0;
const globalImgTime = _w.__lupine_clipboard_img_time || 0;
const useImgGlobal = globalImgTime > 0;
const bestInternalTime = Math.max(globalTime, this.internalClipboardTime);
if (useImgGlobal && globalImgTime > bestInternalTime) {
const img = _w.__lupine_clipboard_img;
if (!img) return;
this._pushUndo();
const newId = `c_${Date.now()}`;
const s: StickerLayer = {
id: newId,
img,
pageIndex: this.cp,
x: 50 / this.zoom,
y: 50 / this.zoom,
w: img.naturalWidth / this.zoom,
h: img.naturalHeight / this.zoom,
rotation: 0,
};
this.stickers.push(s);
this.activeId = newId;
this._drawFg();
return;
}
const internal =
globalTime > this.internalClipboardTime ? _w.__lupine_clipboard : this.internalClipboard || _w.__lupine_clipboard;
if (!internal) return;
this._pushUndo();
const newId = `c_${Date.now()}`;
const offset = 20 / this.zoom;
const pgW = this.canvasFg.width / this.zoom;
const pgH = this.canvasFg.height / this.zoom;
if (internal.type === 'text') {
const t = { ...internal.data, id: newId, pageIndex: this.cp };
t.x += offset;
t.y += offset;
if (t.tailActive && t.tailX !== undefined && t.tailY !== undefined) {
t.tailX += offset;
t.tailY += offset;
}
enforceBounds(t, false, 0, 0, pgW, pgH, this.zoom);
this.texts.push(t);
this.activeId = newId;
} else if (internal.type === 'sticker') {
const s = { ...internal.data, id: newId, pageIndex: this.cp };
s.x += offset;
s.y += offset;
enforceBounds(s, false, 0, 0, pgW, pgH, this.zoom);
this.stickers.push(s);
this.activeId = newId;
} else if (internal.type === 'shape') {
const sh = {
...internal.data,
id: newId,
pageIndex: this.cp,
points: internal.data.points ? internal.data.points.map((p: any) => ({ ...p })) : undefined,
};
if (sh.w === undefined && sh.endX !== undefined) sh.w = sh.endX;
if (sh.h === undefined && sh.endY !== undefined) sh.h = sh.endY;
sh.x += offset;
sh.y += offset;
if (sh.w !== undefined) sh.w += offset;
else if (sh.endX !== undefined) sh.endX += offset;
if (sh.h !== undefined) sh.h += offset;
else if (sh.endY !== undefined) sh.endY += offset;
if (sh.points) {
for (const p of sh.points) {
p.x += offset;
p.y += offset;
}
}
enforceBounds(sh, true, 0, 0, pgW, pgH, this.zoom);
this.shapes.push(sh);
this.activeId = newId;
}
if (this.activeId && !this.renderOrder.includes(this.activeId)) {
this.renderOrder.push(this.activeId);
}
this._setTool('pan', true);
this._drawFg();
}
}