mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
105590d6d7
commit
827f8ab48f
15 files changed
+768
-64
No files matched your search
@@ -90,6 +90,21 @@
|
||||
</select>
|
||||
<span>对象</span>
|
||||
</div>
|
||||
<div class="editor-group editor-transform">
|
||||
<select id="objectRotate" class="order-select" aria-label="对象旋转" disabled>
|
||||
<option value="" hidden>旋转</option>
|
||||
<option value="90">右转90°</option>
|
||||
<option value="-90">左转90°</option>
|
||||
</select>
|
||||
<select id="objectFlip" class="order-select" aria-label="对象翻转" disabled>
|
||||
<option value="" hidden>翻转</option>
|
||||
<option value="horizontal">水平</option>
|
||||
<option value="vertical">垂直</option>
|
||||
</select>
|
||||
<button id="cropImageButton" class="button" type="button" aria-label="裁剪图片" aria-pressed="false" disabled>裁剪</button>
|
||||
<button id="resetCropButton" class="button" type="button" aria-label="还原图片" disabled>还原</button>
|
||||
<span>变换</span>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<div class="text-font"><select id="textFont" aria-label="字体" title="字体" disabled></select><button id="textFontAdd" class="icon-button" type="button" title="添加字体" aria-label="添加字体">+</button></div>
|
||||
<div class="editor-number"><input id="textSize" type="number" min="1" step="any" required aria-label="字号" disabled><span>pt</span></div>
|
||||
|
||||
+25
-3
@@ -9,6 +9,7 @@
|
||||
--page: #ffffff;
|
||||
--text-muted: #66737f;
|
||||
--accent: #0f7b68;
|
||||
--accent-soft: rgba(15, 123, 104, 0.12);
|
||||
--accent-strong: #095c4e;
|
||||
--focus: #0f7b68;
|
||||
--sidebar-width: clamp(220px, 20vw, 292px);
|
||||
@@ -112,6 +113,7 @@ a:focus-visible {
|
||||
|
||||
.editor-file,
|
||||
.editor-paragraph,
|
||||
.editor-transform,
|
||||
.editor-group:has(#textFont),
|
||||
.editor-group:has(#shapeFill) {
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
@@ -1220,6 +1222,7 @@ button.outline-link:hover {
|
||||
background: #fff;
|
||||
outline: none;
|
||||
resize: none;
|
||||
transform-origin: 0 0;
|
||||
font-kerning: none;
|
||||
font-variant-ligatures: none;
|
||||
letter-spacing: 0;
|
||||
@@ -1273,7 +1276,8 @@ button.outline-link:hover {
|
||||
|
||||
.edit-object:hover,
|
||||
.edit-object.selected,
|
||||
.edit-selection {
|
||||
.edit-selection,
|
||||
.edit-crop {
|
||||
outline: calc(1px / var(--surface-scale)) solid var(--accent);
|
||||
}
|
||||
|
||||
@@ -1298,7 +1302,8 @@ button.outline-link:hover {
|
||||
}
|
||||
|
||||
.selected .edit-handle,
|
||||
.edit-selection .edit-handle {
|
||||
.edit-selection .edit-handle,
|
||||
.edit-crop .edit-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1306,11 +1311,28 @@ button.outline-link:hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-selection .edit-handle {
|
||||
.edit-selection .edit-handle,
|
||||
.edit-crop .edit-handle {
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.edit-crop {
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.cropping .edit-object {
|
||||
pointer-events: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cropping .edit-object .edit-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-handle::before {
|
||||
position: absolute;
|
||||
inset: calc(-8px / var(--surface-scale));
|
||||
|
||||
+65
-13
@@ -93,6 +93,10 @@ const el = {
|
||||
objectOrder: document.querySelector("#objectOrder"),
|
||||
objectAlign: document.querySelector("#objectAlign"),
|
||||
objectDistribute: document.querySelector("#objectDistribute"),
|
||||
objectRotate: document.querySelector("#objectRotate"),
|
||||
objectFlip: document.querySelector("#objectFlip"),
|
||||
cropImageButton: document.querySelector("#cropImageButton"),
|
||||
resetCropButton: document.querySelector("#resetCropButton"),
|
||||
multiSelectButton: document.querySelector("#multiSelectButton"),
|
||||
textAlign: document.querySelector("#textAlign"),
|
||||
textWrap: document.querySelector("#textWrap"),
|
||||
@@ -240,7 +244,13 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
|
||||
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
|
||||
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height),
|
||||
onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x, box.width, true, item.align || "", item.paragraphHeight || 0),
|
||||
onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0),
|
||||
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
|
||||
onCropChange: () => {
|
||||
updatePendingChanges();
|
||||
updateObjectControls(canvasEditor.selected);
|
||||
syncSelection();
|
||||
},
|
||||
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
|
||||
onEdit: editCanvasObject,
|
||||
drawStyle: shapeStyle,
|
||||
@@ -253,8 +263,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null),
|
||||
onTextChange: () => {
|
||||
const editing = canvasEditor.input;
|
||||
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
||||
|| Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData))));
|
||||
updatePendingChanges();
|
||||
if (!editing) {
|
||||
updateTextFonts(canvasEditor.selected, true);
|
||||
syncSelection();
|
||||
@@ -262,14 +271,22 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
},
|
||||
});
|
||||
|
||||
// updatePendingChanges 将未提交的文字和裁剪计入修改状态,不生成历史记录。
|
||||
function updatePendingChanges() {
|
||||
const editing = canvasEditor.input;
|
||||
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
||||
|| Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged()));
|
||||
}
|
||||
|
||||
el.editorTools.addEventListener("pointerdown", (event) => {
|
||||
if (canvasEditor.input && event.target.closest("button")) event.preventDefault();
|
||||
});
|
||||
|
||||
// editorClick commits inline text before resolving the selection for a toolbar command.
|
||||
// editorClick 提交画布中的文字和裁剪后,再执行工具栏操作。
|
||||
function editorClick(button, action) {
|
||||
button.addEventListener("click", async () => {
|
||||
if (canvasEditor.input && !await canvasEditor.commitText()) return;
|
||||
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
|
||||
return action();
|
||||
});
|
||||
}
|
||||
@@ -357,6 +374,30 @@ el.objectDistribute.addEventListener("change", () => {
|
||||
return changeDocument("ofdgoDistributeObjects", canvasEditor.selected, axis);
|
||||
}
|
||||
});
|
||||
for (const [select, method] of [[el.objectRotate, "ofdgoRotateObjects"], [el.objectFlip, "ofdgoFlipObjects"]]) {
|
||||
select.addEventListener("change", () => {
|
||||
const value = select.value, item = canvasEditor.selected;
|
||||
select.value = "";
|
||||
if (!item || !value || canvasEditor.crop) return;
|
||||
return changeDocument(method, { ...item, id: canvasEditor.items().map(member => member.id) },
|
||||
select === el.objectRotate ? Number(value) : value);
|
||||
});
|
||||
}
|
||||
el.cropImageButton.addEventListener("click", () => {
|
||||
if (document.body.hasAttribute("aria-busy")) return;
|
||||
if (canvasEditor.crop) return canvasEditor.commitCrop();
|
||||
const item = canvasEditor.selected;
|
||||
if (item?.type === "ImageObject") canvasEditor.startCrop(item);
|
||||
});
|
||||
el.resetCropButton.addEventListener("click", () => {
|
||||
if (document.body.hasAttribute("aria-busy")) return;
|
||||
if (canvasEditor.crop) {
|
||||
canvasEditor.closeCrop();
|
||||
return;
|
||||
}
|
||||
const item = canvasEditor.selected;
|
||||
if (item?.imageBounds) return canvasEditor.options.onCrop(item, item.imageBounds);
|
||||
});
|
||||
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
|
||||
editorClick(el.redoButton, () => changeDocument("ofdgoRedo"));
|
||||
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
|
||||
@@ -760,12 +801,12 @@ function openInsertPanel(item = null) {
|
||||
el.insertImage.focus();
|
||||
}
|
||||
|
||||
// currentTextStyle returns the selected text style or the next text box's defaults.
|
||||
// currentTextStyle 获取所选文字样式,未选中文字时使用新文字的默认样式。
|
||||
function currentTextStyle() {
|
||||
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
|
||||
}
|
||||
|
||||
// toggleTextTool places text directly on the page without creating empty document objects.
|
||||
// toggleTextTool 切换画布文字工具,不提前创建空文字对象。
|
||||
async function toggleTextTool() {
|
||||
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
|
||||
if (canvasEditor.tool === "text") {
|
||||
@@ -788,7 +829,7 @@ async function toggleTextTool() {
|
||||
el.viewerPanel.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// beginCanvasText loads the chosen local font and starts an unsaved inline text box.
|
||||
// beginCanvasText 加载所选本地字体,并在画布上创建待提交的文字输入框。
|
||||
async function beginCanvasText(index, box, surface, page) {
|
||||
const font = state.textDefaults.fontChoice || state.textFonts[Number(el.textFont.value)];
|
||||
if (!font) {
|
||||
@@ -848,7 +889,7 @@ function updateTextFonts(item, refresh = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// changeTextFont preserves uncommitted text while loading a replacement font.
|
||||
// changeTextFont 更换字体时保留尚未提交的文字。
|
||||
async function changeTextFont() {
|
||||
const item = canvasEditor.selected;
|
||||
const font = state.textFonts[Number(el.textFont.value)];
|
||||
@@ -940,7 +981,7 @@ function textPoints(size) {
|
||||
return Number((size * 72 / 25.4).toFixed(2));
|
||||
}
|
||||
|
||||
// changeParagraph keeps layout in the library and only submits changed form values.
|
||||
// changeParagraph 仅提交变更的段落选项,具体排版由库完成。
|
||||
async function changeParagraph(wrap = currentTextStyle().wrap) {
|
||||
const item = currentTextStyle();
|
||||
if (document.body.hasAttribute("aria-busy") || item.draft) return;
|
||||
@@ -2253,9 +2294,10 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
|
||||
if (openSeq !== state.openSeq) {
|
||||
return;
|
||||
}
|
||||
if (canvasEditor.input) {
|
||||
if (canvasEditor.input || canvasEditor.crop) {
|
||||
setBusy(false);
|
||||
if (!await canvasEditor.commitText()) return;
|
||||
if (!await canvasEditor.commitCrop()) return;
|
||||
openSeq = state.openSeq;
|
||||
setBusy(true, `正在生成 ${label}`, null, whole ? STATUS.exporting : STATUS.pageExporting);
|
||||
}
|
||||
@@ -2406,7 +2448,7 @@ function unmountPage(index) {
|
||||
const shell = pageShell(index);
|
||||
const selection = document.getSelection();
|
||||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (canvasEditor.input?.item.index === index
|
||||
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
|
||||
|| range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
|
||||
state.selectedPages.add(index);
|
||||
return;
|
||||
@@ -4160,7 +4202,17 @@ function updateObjectControls(item, reset = false) {
|
||||
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
|
||||
el.deleteObjectButton.disabled = el.objectAlign.disabled = disabled;
|
||||
el.copyObjectButton.disabled = disabled;
|
||||
el.objectDistribute.disabled = disabled || !item.items || item.items.length < 3;
|
||||
const cropping = Boolean(canvasEditor.crop);
|
||||
el.objectAlign.disabled = disabled || cropping;
|
||||
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping;
|
||||
el.cropImageButton.disabled = disabled || item.type !== "ImageObject";
|
||||
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
|
||||
el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片");
|
||||
el.cropImageButton.setAttribute("aria-pressed", String(cropping));
|
||||
el.resetCropButton.textContent = cropping ? "取消" : "还原";
|
||||
el.resetCropButton.setAttribute("aria-label", cropping ? "取消裁剪" : "还原图片");
|
||||
el.resetCropButton.disabled = disabled || !cropping && (!item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
|
||||
el.objectDistribute.disabled = disabled || cropping || !item.items || item.items.length < 3;
|
||||
el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject";
|
||||
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
|
||||
const text = item?.type === "TextObject" ? item : state.textDefaults;
|
||||
@@ -4197,7 +4249,7 @@ function updateObjectControls(item, reset = false) {
|
||||
const count = members[0]?.count || 0;
|
||||
const canRaise = orders.some((order, i) => order !== count - orders.length + i);
|
||||
const canLower = orders.some((order, i) => order !== i);
|
||||
el.objectOrder.disabled = disabled || count <= orders.length;
|
||||
el.objectOrder.disabled = disabled || cropping || count <= orders.length;
|
||||
for (const option of el.objectOrder.options) {
|
||||
option.disabled = disabled || (["up", "top"].includes(option.value) ? !canRaise : !canLower);
|
||||
}
|
||||
|
||||
+173
-17
@@ -21,14 +21,14 @@ export function objectTransform(box, dx, dy, corner = "") {
|
||||
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
|
||||
}
|
||||
|
||||
// selectionBounds keeps same-page objects together without changing their relative positions.
|
||||
// selectionBounds 计算同页对象的联合边界,不改变对象位置。
|
||||
export function selectionBounds(items) {
|
||||
const x = Math.min(...items.map(item => item.x)), y = Math.min(...items.map(item => item.y));
|
||||
return { x, y, width: Math.max(...items.map(item => item.x + item.width)) - x,
|
||||
height: Math.max(...items.map(item => item.y + item.height)) - y };
|
||||
}
|
||||
|
||||
// alignmentGuides marks the nearest edges or centers without changing object coordinates.
|
||||
// alignmentGuides 查找最近的边缘或中心辅助线,不改变对象坐标。
|
||||
function alignmentGuides(box, targets, tolerance) {
|
||||
const lines = [];
|
||||
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
|
||||
@@ -51,7 +51,26 @@ function alignmentGuides(box, targets, tolerance) {
|
||||
return lines.join("");
|
||||
}
|
||||
|
||||
// constrainedPoint preserves shape constraints without changing off-page geometry.
|
||||
// transformedBox 将本地矩形映射到页面,用于变换后的文字框预览。
|
||||
function transformedBox(box, matrix) {
|
||||
const [a, b, c, d, e, f] = matrix;
|
||||
const points = [[box.x, box.y], [box.x + box.width, box.y], [box.x, box.y + box.height], [box.x + box.width, box.y + box.height]]
|
||||
.map(([x, y]) => [a * x + c * y + e, b * x + d * y + f]);
|
||||
const x = Math.min(...points.map(p => p[0])), y = Math.min(...points.map(p => p[1]));
|
||||
return { x, y, width: Math.max(...points.map(p => p[0])) - x, height: Math.max(...points.map(p => p[1])) - y };
|
||||
}
|
||||
|
||||
// cropBox 将保留区域限制在当前图片范围内,不改变图片比例。
|
||||
function cropBox(box, bounds, dx, dy, corner) {
|
||||
if (!corner) return { ...box, x: Math.max(bounds.x, Math.min(box.x + dx, bounds.x + bounds.width - box.width)),
|
||||
y: Math.max(bounds.y, Math.min(box.y + dy, bounds.y + bounds.height - box.height)) };
|
||||
const next = reshapeBox({ geometry: box }, dx, dy, corner, false);
|
||||
const x = Math.max(bounds.x, next.x), y = Math.max(bounds.y, next.y);
|
||||
return { x, y, width: Math.min(bounds.x + bounds.width, next.x + next.width) - x,
|
||||
height: Math.min(bounds.y + bounds.height, next.y + next.height) - y };
|
||||
}
|
||||
|
||||
// constrainedPoint 按修饰键约束图形端点,允许超出页面边界。
|
||||
export function constrainedPoint(from, to, shape, shift) {
|
||||
let dx = to.x - from.x, dy = to.y - from.y;
|
||||
if (shift && shape === "line") {
|
||||
@@ -68,7 +87,7 @@ export function constrainedPoint(from, to, shape, shift) {
|
||||
return { x: from.x + dx, y: from.y + dy };
|
||||
}
|
||||
|
||||
// reshapeBox moves line endpoints or resizes basic shapes without scaling their stroke.
|
||||
// reshapeBox 移动线段端点或调整基本图形范围,不缩放描边。
|
||||
export function reshapeBox(item, dx, dy, handle, shift) {
|
||||
const box = item.geometry;
|
||||
if (!dx && !dy && !shift) return { ...box };
|
||||
@@ -93,7 +112,7 @@ export function reshapeBox(item, dx, dy, handle, shift) {
|
||||
return { x, y, width: right - x, height: bottom - y };
|
||||
}
|
||||
|
||||
// paintShape uses the same page-space geometry for creation and endpoint previews.
|
||||
// paintShape 使用统一的页面坐标绘制图形及端点调整预览。
|
||||
function paintShape(node, shape, box) {
|
||||
const { x, y, width, height } = box;
|
||||
const attributes = shape === "line" ? { x1: x, y1: y, x2: x + width, y2: y + height }
|
||||
@@ -152,6 +171,8 @@ export class CanvasEditor {
|
||||
}
|
||||
|
||||
mount(index, page, surface) {
|
||||
const crop = this.crop?.item.index === index ? this.crop : null;
|
||||
if (crop) this.closeCrop();
|
||||
const pending = this.pendingSelection?.index === index;
|
||||
const selected = pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id)
|
||||
: this.items().filter(item => item.index === index).map(item => item.id);
|
||||
@@ -189,6 +210,12 @@ export class CanvasEditor {
|
||||
surface.append(layer);
|
||||
if (selected.length) this.setSelection(selection);
|
||||
if (pending) this.pendingSelection = null;
|
||||
if (crop && selection.length === 1 && selection[0].id === crop.item.id
|
||||
&& ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) {
|
||||
this.startCrop(selection[0]);
|
||||
this.crop.box = { ...crop.box };
|
||||
this.paintCrop();
|
||||
}
|
||||
}
|
||||
|
||||
place(item, change = { x: 0, y: 0, scale: 1 }) {
|
||||
@@ -208,9 +235,19 @@ export class CanvasEditor {
|
||||
left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`,
|
||||
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
|
||||
});
|
||||
if (item.textFrame) {
|
||||
const { matrix: [a, b, c, d, e, f], width, height } = item.textFrame;
|
||||
for (const handle of item.node.children) {
|
||||
if (handle.dataset.corner !== "w" && handle.dataset.corner !== "e") continue;
|
||||
const x = handle.dataset.corner === "e" ? width : 0;
|
||||
handle.style.left = `${(a * x + c * height / 2 + e - item.x) * change.scale * PX_PER_MM}px`;
|
||||
handle.style.top = `${(b * x + d * height / 2 + f - item.y) * change.scale * PX_PER_MM}px`;
|
||||
handle.style.cursor = a === 0 ? "var(--resize-vertical, ns-resize)" : "var(--resize-horizontal, ew-resize)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// placeShape positions basic-shape handles at their geometry rather than the padded boundary.
|
||||
// placeShape 按图形几何范围放置手柄,不包含边界留量。
|
||||
placeShape(item, box) {
|
||||
const x = Math.min(box.x, box.x + box.width), y = Math.min(box.y, box.y + box.height);
|
||||
Object.assign(item.node.style, {
|
||||
@@ -226,13 +263,14 @@ export class CanvasEditor {
|
||||
}
|
||||
}
|
||||
|
||||
// items returns the current selection without treating a group as a document object.
|
||||
// items 获取选中的真实对象,临时多选框不作为文档对象。
|
||||
items() {
|
||||
return this.selected?.items || (this.selected ? [this.selected] : []);
|
||||
}
|
||||
|
||||
// setSelection updates object highlights and exposes one same-page selection to the toolbar.
|
||||
// setSelection 更新同页对象选区,并同步工具栏。
|
||||
setSelection(items) {
|
||||
if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop();
|
||||
if (this.selected?.items) this.selected.node.remove();
|
||||
for (const item of this.items()) {
|
||||
item.node.classList.remove("selected", "multi-selected");
|
||||
@@ -273,6 +311,7 @@ export class CanvasEditor {
|
||||
|
||||
clear() {
|
||||
this.closeText();
|
||||
this.closeCrop();
|
||||
this.cancel();
|
||||
this.pendingSelection = null;
|
||||
this.select(null);
|
||||
@@ -286,6 +325,19 @@ export class CanvasEditor {
|
||||
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
||||
return;
|
||||
}
|
||||
if (this.crop) {
|
||||
if (!event.target.closest(".edit-crop")) {
|
||||
this.commitCrop();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.viewer.focus({ preventScroll: true });
|
||||
const { item, box } = this.crop;
|
||||
this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
|
||||
page: item.page, rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY };
|
||||
this.viewer.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
if (this.tool) {
|
||||
this.startShape(event);
|
||||
return;
|
||||
@@ -336,6 +388,13 @@ export class CanvasEditor {
|
||||
}
|
||||
event.preventDefault();
|
||||
drag.pointer = event;
|
||||
if (drag.crop) {
|
||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
|
||||
this.crop.box = cropBox(drag.box, this.crop.item, to.x - from.x, to.y - from.y, drag.corner);
|
||||
this.paintCrop();
|
||||
return;
|
||||
}
|
||||
if (drag.shape) {
|
||||
this.moveShape(event, drag);
|
||||
return;
|
||||
@@ -350,6 +409,16 @@ export class CanvasEditor {
|
||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
|
||||
if (drag.item.textFrame) {
|
||||
const frame = drag.item.textFrame, [a, b, c, d] = frame.matrix;
|
||||
const dx = (d * (to.x - from.x) - c * (to.y - from.y)) / (a * d - b * c);
|
||||
const box = reshapeBox({ geometry: { x: 0, y: 0, width: frame.width, height: frame.height } }, dx, 0, drag.corner, false);
|
||||
drag.box = { x: drag.item.x + box.x, width: box.width };
|
||||
const bounds = transformedBox(box, frame.matrix);
|
||||
Object.assign(drag.item.node.style, { left: `${bounds.x * PX_PER_MM}px`, top: `${bounds.y * PX_PER_MM}px`,
|
||||
width: `${bounds.width * PX_PER_MM}px`, height: `${bounds.height * PX_PER_MM}px` });
|
||||
return;
|
||||
}
|
||||
drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, 0, drag.corner, false);
|
||||
Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, width: `${drag.box.width * PX_PER_MM}px` });
|
||||
return;
|
||||
@@ -365,7 +434,7 @@ export class CanvasEditor {
|
||||
if (!drag.corner) this.showGuides(drag);
|
||||
}
|
||||
|
||||
// showGuides keeps the visual threshold constant through page zoom and rotation.
|
||||
// showGuides 在页面缩放和旋转时保持辅助线的屏幕判定距离一致。
|
||||
showGuides(drag) {
|
||||
const { item, rect, rotation, change } = drag;
|
||||
const path = alignmentGuides({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
|
||||
@@ -388,6 +457,11 @@ export class CanvasEditor {
|
||||
return;
|
||||
}
|
||||
this.move(event);
|
||||
if (drag.crop) {
|
||||
this.drag = null;
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
return;
|
||||
}
|
||||
if (drag.marquee) {
|
||||
this.drag = null;
|
||||
drag.preview.remove();
|
||||
@@ -419,7 +493,7 @@ export class CanvasEditor {
|
||||
if ((drag.item.shape || drag.item.type === "TextObject") && drag.corner && drag.box) {
|
||||
this.drag = null;
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
const geometry = drag.item.geometry || drag.item;
|
||||
const geometry = drag.item.geometry || (drag.item.textFrame ? { x: drag.item.x, width: drag.item.textFrame.width } : drag.item);
|
||||
const changed = Object.keys(drag.box).some((key) => drag.box[key] !== geometry[key]);
|
||||
const apply = drag.item.shape ? this.options.onReshape : this.options.onTextWidth;
|
||||
Promise.resolve(changed && apply(drag.item, drag.box)).finally(() => {
|
||||
@@ -438,6 +512,10 @@ export class CanvasEditor {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (drag) {
|
||||
if (drag.crop) {
|
||||
this.crop.box = drag.box;
|
||||
this.paintCrop();
|
||||
}
|
||||
drag.preview?.remove();
|
||||
drag.guides?.preview.remove();
|
||||
if (drag.marquee) this.setSelection(drag.before);
|
||||
@@ -450,7 +528,7 @@ export class CanvasEditor {
|
||||
}
|
||||
}
|
||||
|
||||
// startMarquee selects by page-space geometry, so rotation and zoom use the same hit tests.
|
||||
// startMarquee 在页面坐标中开始框选,统一处理缩放和旋转。
|
||||
startMarquee(event, additive) {
|
||||
const surface = event.target.closest(".page-surface"), page = this.pages.get(surface);
|
||||
if (!page) {
|
||||
@@ -468,7 +546,7 @@ export class CanvasEditor {
|
||||
this.viewer.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
// moveMarquee includes intersecting objects while preserving an additive selection.
|
||||
// moveMarquee 选中与框选区域相交的对象,支持追加到已有选区。
|
||||
moveMarquee(event, drag) {
|
||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
|
||||
@@ -505,7 +583,7 @@ export class CanvasEditor {
|
||||
this.viewer.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
// nearestSurface assigns a pasteboard gesture to one mounted page for its entire duration.
|
||||
// nearestSurface 为页面外开始的手势确定一个已加载页面,拖动期间不切换页面。
|
||||
nearestSurface(event) {
|
||||
let nearest = null, distance = Infinity;
|
||||
for (const surface of this.viewer.querySelectorAll(".page-surface")) {
|
||||
@@ -521,7 +599,7 @@ export class CanvasEditor {
|
||||
return nearest;
|
||||
}
|
||||
|
||||
// createPreview provides a temporary SVG overlay without rerendering the page during a drag.
|
||||
// createPreview 创建临时SVG预览层,拖动期间不重新渲染页面。
|
||||
createPreview(surface, page, shape, style) {
|
||||
const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
preview.classList.add("edit-preview");
|
||||
@@ -553,7 +631,7 @@ export class CanvasEditor {
|
||||
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height });
|
||||
}
|
||||
|
||||
// modifierChange refreshes the preview even when Shift changes without pointer movement.
|
||||
// modifierChange 在指针静止时响应Shift变化并刷新预览。
|
||||
modifierChange(event) {
|
||||
const pointer = this.drag?.pointer;
|
||||
if (event.key === "Shift" && pointer) {
|
||||
@@ -564,6 +642,16 @@ export class CanvasEditor {
|
||||
|
||||
keyDown(event) {
|
||||
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||
if (this.crop && !this.options.busy()) {
|
||||
if (event.key === "Escape" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") this.commitCrop();
|
||||
else if (this.drag) this.cancel();
|
||||
else this.closeCrop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.modifierChange(event);
|
||||
if (event.key === "Escape" && this.drag && !this.tool && !this.options.busy()) {
|
||||
event.preventDefault();
|
||||
@@ -620,6 +708,11 @@ export class CanvasEditor {
|
||||
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
|
||||
textAlign: item.align || "left",
|
||||
});
|
||||
if (item.textFrame) {
|
||||
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
|
||||
Object.assign(input.style, { left: "0px", top: "0px", width: `${Math.max(width, item.wrap ? width : 50) * PX_PER_MM + 4}px`,
|
||||
minHeight: `${height * PX_PER_MM + 4}px`, transform: `matrix(${a},${b},${c},${d},${e * PX_PER_MM},${f * PX_PER_MM})` });
|
||||
}
|
||||
this.input = { input, item, face, fontChoice: item.fontChoice };
|
||||
item.surface.append(input);
|
||||
input.addEventListener("input", () => {
|
||||
@@ -648,14 +741,14 @@ export class CanvasEditor {
|
||||
if (!item.draft) input.select();
|
||||
}
|
||||
|
||||
// resizeText follows the draft's current font and content without changing document geometry.
|
||||
// resizeText 按当前字体和内容调整输入框,不修改文档中的几何范围。
|
||||
resizeText() {
|
||||
const { input } = this.input;
|
||||
input.style.height = "0px";
|
||||
input.style.height = `${input.scrollHeight + 2}px`;
|
||||
}
|
||||
|
||||
// setTextFont changes only the inline buffer until text and font are committed together.
|
||||
// setTextFont 暂存输入框字体,后续与文字内容一起提交。
|
||||
setTextFont(face, data) {
|
||||
const editing = this.input;
|
||||
document.fonts.add(face);
|
||||
@@ -700,4 +793,67 @@ export class CanvasEditor {
|
||||
this.options.onTextChange();
|
||||
}
|
||||
}
|
||||
|
||||
// startCrop 开始调整图片保留区域,确认前不修改文档。
|
||||
startCrop(item) {
|
||||
this.cancel();
|
||||
this.closeCrop();
|
||||
this.setTool("");
|
||||
const box = { x: item.x, y: item.y, width: item.width, height: item.height };
|
||||
const { preview, node: mask } = this.createPreview(item.surface, item.page, "path", { fill: true, fillColor: "rgba(255,255,255,0.72)" });
|
||||
mask.setAttribute("fill-rule", "evenodd");
|
||||
const node = document.createElement("div");
|
||||
node.className = "edit-layer edit-crop";
|
||||
for (const corner of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) {
|
||||
const handle = document.createElement("span");
|
||||
handle.className = `edit-handle edit-${corner}`;
|
||||
handle.dataset.corner = corner;
|
||||
node.append(handle);
|
||||
}
|
||||
item.surface.append(node);
|
||||
item.surface.classList.add("cropping");
|
||||
this.crop = { item, box, node, mask, preview };
|
||||
this.paintCrop();
|
||||
this.viewer.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// paintCrop 更新遮罩及手柄,不重新渲染或复制图片数据。
|
||||
paintCrop() {
|
||||
const { item, box, node, mask } = this.crop;
|
||||
const rectangle = b => `M${b.x} ${b.y}h${b.width}v${b.height}h${-b.width}Z`;
|
||||
mask.setAttribute("d", rectangle(item) + rectangle(box));
|
||||
Object.assign(node.style, { left: `${box.x * PX_PER_MM}px`, top: `${box.y * PX_PER_MM}px`, width: `${box.width * PX_PER_MM}px`, height: `${box.height * PX_PER_MM}px` });
|
||||
this.options.onCropChange();
|
||||
}
|
||||
|
||||
// cropChanged 判断裁剪范围是否有待提交的变更。
|
||||
cropChanged() {
|
||||
return Boolean(this.crop && ["x", "y", "width", "height"].some(key => this.crop.box[key] !== this.crop.item[key]));
|
||||
}
|
||||
|
||||
// commitCrop 将裁剪作为一次可撤销操作提交,失败时保留预览。
|
||||
async commitCrop() {
|
||||
if (!this.crop) return true;
|
||||
if (this.options.busy()) return false;
|
||||
if (!this.cropChanged()) {
|
||||
this.closeCrop();
|
||||
return true;
|
||||
}
|
||||
const crop = this.crop;
|
||||
const saved = await this.options.onCrop(crop.item, crop.box);
|
||||
if (saved && this.crop === crop) this.closeCrop();
|
||||
return Boolean(saved);
|
||||
}
|
||||
|
||||
// closeCrop 移除临时裁剪区域,不修改文档内容。
|
||||
closeCrop() {
|
||||
if (!this.crop) return;
|
||||
if (this.drag?.crop) this.cancel();
|
||||
const crop = this.crop;
|
||||
this.crop = null;
|
||||
crop.node.remove();
|
||||
crop.preview.remove();
|
||||
crop.item.surface.classList.remove("cropping");
|
||||
this.options.onCropChange();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user