feat(创建编辑): 完善图片适应、裁剪恢复和字距设置,统一工具栏对齐
build.yaml / build (push) Successful in 36s

This commit is contained in:
xiaoqidun committed 2026-09-16 00:52:53 +08:00
1 parent 827f8ab48f
commit 89797af8d6
8 files changed
+310 -88

No files matched your search

+15 -2
View File
@@ -101,9 +101,17 @@
<option value="horizontal">水平</option>
<option value="vertical">垂直</option>
</select>
<span>变换</span>
</div>
<div class="editor-group editor-image">
<select id="imageFit" aria-label="图片适应方式" disabled>
<option value="" hidden>缩放</option>
<option value="contain">适应</option>
<option value="cover">填充</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>
<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>
@@ -118,8 +126,9 @@
<option value="right">右齐</option>
<option value="justify">两端</option>
</select>
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></span></label>
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
<label class="editor-field"><span>字距</span><span class="editor-number"><input id="textSpacing" type="number" step="any" placeholder="0" aria-label="字距" title="字距" disabled><span>pt</span></span></label>
<span>段落</span>
</div>
<div class="editor-group">
@@ -201,6 +210,10 @@
<label for="insertImage">图片</label>
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg" required autofocus>
</div>
<div id="insertFitRow" class="form-row" hidden>
<label for="insertFit">缩放</label>
<select id="insertFit"><option value="contain">适应</option><option value="cover">填充</option></select>
</div>
<div class="form-actions">
<div id="insertStatus" class="form-status" role="status"></div>
<button id="insertCancel" class="button" type="button">取消</button>
+23 -3
View File
@@ -113,12 +113,24 @@ a:focus-visible {
.editor-file,
.editor-paragraph,
.editor-transform,
.editor-image,
.editor-group:has(#textFont),
.editor-group:has(#shapeFill) {
grid-template-columns: repeat(2, max-content);
}
.editor-transform {
grid-template-columns: max-content;
}
.editor-image > select {
grid-column: 1 / -1;
}
.editor-paragraph > .editor-field {
grid-column: auto;
}
.editor-objects {
grid-template-columns: repeat(4, max-content);
}
@@ -178,8 +190,8 @@ a:focus-visible {
}
.editor-paragraph {
width: 172px;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 218px;
grid-template-columns: 54px minmax(0, 1fr);
}
.editor-file #saveButton {
@@ -196,6 +208,14 @@ a:focus-visible {
min-width: 0;
}
#textColor {
width: 100%;
}
.shape-paint {
justify-content: space-between;
}
.shape-paint,
.shape-paint label {
display: flex;
+67 -20
View File
@@ -39,7 +39,7 @@ const state = {
insertObject: null,
textFonts: [],
textFontID: null,
textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0 },
textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 },
ofdBytes: null,
fileName: "ofdgo.ofd",
openSeq: 0,
@@ -95,12 +95,14 @@ const el = {
objectDistribute: document.querySelector("#objectDistribute"),
objectRotate: document.querySelector("#objectRotate"),
objectFlip: document.querySelector("#objectFlip"),
imageFit: document.querySelector("#imageFit"),
cropImageButton: document.querySelector("#cropImageButton"),
resetCropButton: document.querySelector("#resetCropButton"),
multiSelectButton: document.querySelector("#multiSelectButton"),
textAlign: document.querySelector("#textAlign"),
textWrap: document.querySelector("#textWrap"),
textLineHeight: document.querySelector("#textLineHeight"),
textSpacing: document.querySelector("#textSpacing"),
textFont: document.querySelector("#textFont"),
textFontAdd: document.querySelector("#textFontAdd"),
textSize: document.querySelector("#textSize"),
@@ -142,6 +144,8 @@ const el = {
insertPanel: document.querySelector("#insertPanel"),
insertForm: document.querySelector("#insertForm"),
insertImage: document.querySelector("#insertImage"),
insertFitRow: document.querySelector("#insertFitRow"),
insertFit: document.querySelector("#insertFit"),
insertStatus: document.querySelector("#insertStatus"),
insertCancel: document.querySelector("#insertCancel"),
insertSubmit: document.querySelector("#insertSubmit"),
@@ -244,7 +248,7 @@ 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 - item.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, item.letterSpacing || 0),
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
onCropChange: () => {
updatePendingChanges();
@@ -259,7 +263,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
onDrawText: beginCanvasText,
onCommitText: (item, value, fontData) => item.draft
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight)
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing)
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null),
onTextChange: () => {
const editing = canvasEditor.input;
@@ -271,7 +275,6 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
},
});
// updatePendingChanges 将未提交的文字和裁剪计入修改状态,不生成历史记录。
function updatePendingChanges() {
const editing = canvasEditor.input;
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
@@ -282,7 +285,6 @@ el.editorTools.addEventListener("pointerdown", (event) => {
if (canvasEditor.input && event.target.closest("button")) event.preventDefault();
});
// editorClick 提交画布中的文字和裁剪后,再执行工具栏操作。
function editorClick(button, action) {
button.addEventListener("click", async () => {
if (canvasEditor.input && !await canvasEditor.commitText()) return;
@@ -323,9 +325,10 @@ el.textColor.addEventListener("change", () => changeTextStyle(true));
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap));
el.textAlign.addEventListener("change", () => changeParagraph());
el.textLineHeight.addEventListener("change", () => changeParagraph());
el.textSpacing.addEventListener("change", () => changeParagraph());
el.textFont.addEventListener("change", changeTextFont);
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight]) {
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) {
input.addEventListener("keydown", (event) => {
if (event.isComposing) return;
if (event.key === "Enter" || event.key === "Escape") {
@@ -387,7 +390,12 @@ 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);
if (item?.type === "ImageObject") return startImageCrop(item);
});
el.imageFit.addEventListener("change", () => {
const mode = el.imageFit.value, item = canvasEditor.selected;
el.imageFit.value = "";
if (mode && item?.type === "ImageObject" && !canvasEditor.crop) return changeDocument("ofdgoFitImage", item, mode);
});
el.resetCropButton.addEventListener("click", () => {
if (document.body.hasAttribute("aria-busy")) return;
@@ -788,12 +796,46 @@ async function editCanvasObject(item) {
}
}
async function startImageCrop(item) {
const openSeq = state.openSeq;
const urls = [];
let mounted = false;
setBusy(true);
try {
const page = await callWASM("ofdgoPreviewImage", item.index, item.id);
if (openSeq !== state.openSeq || !item.surface.isConnected) return;
const images = new Map(state.svgImages);
for (const image of page.images) {
if (images.has(image.name)) continue;
const url = URL.createObjectURL(new Blob([image.bytes], { type: image.mime }));
urls.push(url);
images.set(image.name, { url });
}
await Promise.all(urls.map(url => {
const image = new Image();
image.src = url;
return image.decode();
}));
if (openSeq !== state.openSeq || canvasEditor.selected !== item) return;
const svg = parseSVG(page.svg, `crop${openSeq}-${item.id}`, images);
svg.classList.add("ofd-svg");
canvasEditor.startCrop(item, { svg, urls });
mounted = true;
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (!mounted) urls.forEach(url => URL.revokeObjectURL(url));
if (openSeq === state.openSeq) setBusy(false);
}
}
function openInsertPanel(item = null) {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
el.insertForm.reset();
state.insertObject = item;
el.insertFitRow.hidden = !item;
el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片");
el.insertSubmit.textContent = item ? "替换" : "添加";
el.insertStatus.textContent = "";
@@ -801,12 +843,10 @@ function openInsertPanel(item = null) {
el.insertImage.focus();
}
// currentTextStyle 获取所选文字样式,未选中文字时使用新文字的默认样式。
function currentTextStyle() {
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
}
// toggleTextTool 切换画布文字工具,不提前创建空文字对象。
async function toggleTextTool() {
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
if (canvasEditor.tool === "text") {
@@ -821,7 +861,7 @@ async function toggleTextTool() {
if (openSeq !== state.openSeq) return;
const style = currentTextStyle();
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0,
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0,
fontChoice: state.textFonts[Number(el.textFont.value)] || state.textDefaults.fontChoice };
state.selectObjects = true;
setPan(false);
@@ -829,7 +869,6 @@ async function toggleTextTool() {
el.viewerPanel.focus({ preventScroll: true });
}
// beginCanvasText 加载所选本地字体,并在画布上创建待提交的文字输入框。
async function beginCanvasText(index, box, surface, page) {
const font = state.textDefaults.fontChoice || state.textFonts[Number(el.textFont.value)];
if (!font) {
@@ -889,7 +928,6 @@ function updateTextFonts(item, refresh = false) {
}
}
// changeTextFont 更换字体时保留尚未提交的文字。
async function changeTextFont() {
const item = canvasEditor.selected;
const font = state.textFonts[Number(el.textFont.value)];
@@ -943,7 +981,7 @@ async function insertObject(event) {
return;
}
const doc = item
? await callWASM("ofdgoReplaceImage", item.index, item.id, data)
? await callWASM("ofdgoReplaceImage", item.index, item.id, data, el.insertFit.value)
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
if (openSeq !== state.openSeq) {
return;
@@ -981,7 +1019,6 @@ function textPoints(size) {
return Number((size * 72 / 25.4).toFixed(2));
}
// changeParagraph 仅提交变更的段落选项,具体排版由库完成。
async function changeParagraph(wrap = currentTextStyle().wrap) {
const item = currentTextStyle();
if (document.body.hasAttribute("aria-busy") || item.draft) return;
@@ -991,12 +1028,18 @@ async function changeParagraph(wrap = currentTextStyle().wrap) {
}
const points = Number(el.textLineHeight.value);
const height = points === textPoints(item.paragraphHeight || 0) ? item.paragraphHeight || 0 : points * 25.4 / 72;
if (item === state.textDefaults) {
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height });
if (!el.textSpacing.checkValidity()) {
updateObjectControls(canvasEditor.selected, true);
return;
}
await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height);
const spacingPoints = Number(el.textSpacing.value);
const spacing = spacingPoints === textPoints(item.letterSpacing || 0) ? item.letterSpacing || 0 : spacingPoints * 25.4 / 72;
if (item === state.textDefaults) {
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height, letterSpacing: spacing });
updateObjectControls(canvasEditor.selected, true);
return;
}
await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing);
updateObjectControls(canvasEditor.selected, true);
}
@@ -2984,9 +3027,9 @@ function layoutPageShell(shell, page) {
}
}
function parseSVG(svgText, prefix = "") {
function parseSVG(svgText, prefix = "", images = state.svgImages) {
const template = document.createElement("template");
template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${state.svgImages.get(name).url}"`);
template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${images.get(name).url}"`);
const svg = document.adoptNode(template.content.firstElementChild);
prefixSVGIds(svg, prefix);
return svg;
@@ -4206,6 +4249,7 @@ function updateObjectControls(item, reset = false) {
el.objectAlign.disabled = disabled || cropping;
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping;
el.cropImageButton.disabled = disabled || item.type !== "ImageObject";
el.imageFit.disabled = el.cropImageButton.disabled || cropping;
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片");
el.cropImageButton.setAttribute("aria-pressed", String(cropping));
@@ -4219,12 +4263,15 @@ function updateObjectControls(item, reset = false) {
el.textFont.disabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items);
el.textSize.disabled = el.textColor.disabled = el.textFont.disabled || Boolean(item?.draft);
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSize.disabled;
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled;
el.textAlign.value = text.align || "left";
el.textWrap.setAttribute("aria-pressed", String(Boolean(text.wrap)));
if (reset || document.activeElement !== el.textLineHeight) {
el.textLineHeight.value = text.paragraphHeight ? String(textPoints(text.paragraphHeight)) : "";
}
if (reset || document.activeElement !== el.textSpacing) {
el.textSpacing.value = text.letterSpacing ? String(textPoints(text.letterSpacing)) : "";
}
updateTextFonts(item);
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
el.textSize.value = document.activeElement === el.textSize ? textPoints(text.size) : Math.round(textPoints(text.size));
+19 -32
View File
@@ -21,14 +21,12 @@ export function objectTransform(box, dx, dy, corner = "") {
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
}
// 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 查找最近的边缘或中心辅助线,不改变对象坐标。
function alignmentGuides(box, targets, tolerance) {
const lines = [];
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
@@ -51,7 +49,6 @@ function alignmentGuides(box, targets, tolerance) {
return lines.join("");
}
// 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]]
@@ -60,7 +57,6 @@ function transformedBox(box, matrix) {
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)) };
@@ -70,7 +66,6 @@ function cropBox(box, bounds, dx, dy, corner) {
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") {
@@ -87,7 +82,6 @@ export function constrainedPoint(from, to, shape, shift) {
return { x: from.x + dx, y: from.y + dy };
}
// reshapeBox 移动线段端点或调整基本图形范围,不缩放描边。
export function reshapeBox(item, dx, dy, handle, shift) {
const box = item.geometry;
if (!dx && !dy && !shift) return { ...box };
@@ -112,7 +106,6 @@ export function reshapeBox(item, dx, dy, handle, shift) {
return { x, y, width: right - x, height: bottom - y };
}
// 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 }
@@ -172,7 +165,7 @@ export class CanvasEditor {
mount(index, page, surface) {
const crop = this.crop?.item.index === index ? this.crop : null;
if (crop) this.closeCrop();
if (crop) this.closeCrop(true);
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);
@@ -212,9 +205,11 @@ export class CanvasEditor {
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.startCrop(selection[0], crop.pagePreview);
this.crop.box = { ...crop.box };
this.paintCrop();
} else if (crop) {
crop.pagePreview.urls.forEach(url => URL.revokeObjectURL(url));
}
}
@@ -247,7 +242,6 @@ export class CanvasEditor {
}
}
// 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, {
@@ -263,12 +257,10 @@ export class CanvasEditor {
}
}
// items 获取选中的真实对象,临时多选框不作为文档对象。
items() {
return this.selected?.items || (this.selected ? [this.selected] : []);
}
// setSelection 更新同页对象选区,并同步工具栏。
setSelection(items) {
if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop();
if (this.selected?.items) this.selected.node.remove();
@@ -391,7 +383,7 @@ export class CanvasEditor {
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.crop.box = cropBox(drag.box, this.crop.item.imageBounds, to.x - from.x, to.y - from.y, drag.corner);
this.paintCrop();
return;
}
@@ -434,7 +426,6 @@ export class CanvasEditor {
if (!drag.corner) this.showGuides(drag);
}
// 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, {
@@ -528,7 +519,6 @@ export class CanvasEditor {
}
}
// startMarquee 在页面坐标中开始框选,统一处理缩放和旋转。
startMarquee(event, additive) {
const surface = event.target.closest(".page-surface"), page = this.pages.get(surface);
if (!page) {
@@ -546,7 +536,6 @@ export class CanvasEditor {
this.viewer.setPointerCapture(event.pointerId);
}
// 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);
@@ -583,7 +572,6 @@ export class CanvasEditor {
this.viewer.setPointerCapture(event.pointerId);
}
// nearestSurface 为页面外开始的手势确定一个已加载页面,拖动期间不切换页面。
nearestSurface(event) {
let nearest = null, distance = Infinity;
for (const surface of this.viewer.querySelectorAll(".page-surface")) {
@@ -599,7 +587,6 @@ export class CanvasEditor {
return nearest;
}
// createPreview 创建临时SVG预览层,拖动期间不重新渲染页面。
createPreview(surface, page, shape, style) {
const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg");
preview.classList.add("edit-preview");
@@ -631,7 +618,6 @@ export class CanvasEditor {
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height });
}
// modifierChange 在指针静止时响应Shift变化并刷新预览。
modifierChange(event) {
const pointer = this.drag?.pointer;
if (event.key === "Shift" && pointer) {
@@ -707,6 +693,7 @@ export class CanvasEditor {
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
textAlign: item.align || "left",
letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`,
});
if (item.textFrame) {
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
@@ -741,14 +728,12 @@ export class CanvasEditor {
if (!item.draft) input.select();
}
// resizeText 按当前字体和内容调整输入框,不修改文档中的几何范围。
resizeText() {
const { input } = this.input;
input.style.height = "0px";
input.style.height = `${input.scrollHeight + 2}px`;
}
// setTextFont 暂存输入框字体,后续与文字内容一起提交。
setTextFont(face, data) {
const editing = this.input;
document.fonts.add(face);
@@ -794,12 +779,16 @@ export class CanvasEditor {
}
}
// startCrop 开始调整图片保留区域,确认前不修改文档。
startCrop(item) {
startCrop(item, pagePreview) {
this.cancel();
this.closeCrop();
this.setTool("");
const box = { x: item.x, y: item.y, width: item.width, height: item.height };
const bounds = item.imageBounds;
const x = Math.max(item.x, bounds.x), y = Math.max(item.y, bounds.y);
const box = { x, y, width: Math.min(item.x + item.width, bounds.x + bounds.width) - x,
height: Math.min(item.y + item.height, bounds.y + bounds.height) - y };
const original = item.surface.querySelector(".ofd-svg");
original.replaceWith(pagePreview.svg);
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");
@@ -812,26 +801,23 @@ export class CanvasEditor {
}
item.surface.append(node);
item.surface.classList.add("cropping");
this.crop = { item, box, node, mask, preview };
this.crop = { item, box, initialBox: { ...box }, node, mask, preview, original, pagePreview };
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));
mask.setAttribute("d", rectangle(item.imageBounds) + 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]));
return Boolean(this.crop && ["x", "y", "width", "height"].some(key => this.crop.box[key] !== this.crop.initialBox[key]));
}
// commitCrop 将裁剪作为一次可撤销操作提交,失败时保留预览。
async commitCrop() {
if (!this.crop) return true;
if (this.options.busy()) return false;
@@ -845,14 +831,15 @@ export class CanvasEditor {
return Boolean(saved);
}
// closeCrop 移除临时裁剪区域,不修改文档内容。
closeCrop() {
closeCrop(keepPreview = false) {
if (!this.crop) return;
if (this.drag?.crop) this.cancel();
const crop = this.crop;
this.crop = null;
crop.node.remove();
crop.preview.remove();
crop.pagePreview.svg.replaceWith(crop.original);
if (!keepPreview) crop.pagePreview.urls.forEach(url => URL.revokeObjectURL(url));
crop.item.surface.classList.remove("cropping");
this.options.onCropChange();
}