feat(编辑能力): 优化复杂文档编辑性能,保持渲染一致
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-18 20:28:33 +08:00
1 parent 92ddacaf66
commit e7686b5430
5 files changed
+128 -51

No files matched your search

+20 -14
View File
@@ -307,8 +307,8 @@ export class CanvasEditor {
const crop = this.crop?.item.index === index ? this.crop : null; const crop = this.crop?.item.index === index ? this.crop : null;
if (crop) this.closeCrop(true); if (crop) this.closeCrop(true);
const pending = this.pendingSelection?.index === index; const pending = this.pendingSelection?.index === index;
const selected = pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id) const selected = new Set(pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id)
: this.items().filter(item => item.index === index).map(item => item.id); : this.items().filter(item => item.index === index).map(item => item.id));
const selection = []; const selection = [];
this.pages.set(surface, { index, width: page.width, height: page.height }); this.pages.set(surface, { index, width: page.width, height: page.height });
const layer = document.createElement("div"); const layer = document.createElement("div");
@@ -359,24 +359,14 @@ export class CanvasEditor {
} }
this.nodes.set(node, item); this.nodes.set(node, item);
node.addEventListener("focus", () => this.select(item)); node.addEventListener("focus", () => this.select(item));
const handles = !canEditObject(object, "transform") ? [] : lineShape(object.shape) ? ["start", "end"]
: object.shape || object.oriented || canEditObject(object, "stretch") ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
: object.type === "TextObject" && canEditObject(object, "reflow") && canEditObject(object, "layoutKnown")
? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"];
for (const corner of handles) {
const handle = document.createElement("span");
handle.className = `edit-handle edit-${corner}`;
handle.dataset.corner = corner;
node.append(handle);
}
this.place(item); this.place(item);
layer.append(node); layer.append(node);
if (selected.includes(object.id)) { if (selected.has(object.id)) {
selection.push(item); selection.push(item);
} }
} }
surface.append(layer); surface.append(layer);
if (selected.length) this.setSelection(selection); if (selected.size) this.setSelection(selection);
if (pending) this.pendingSelection = null; if (pending) this.pendingSelection = null;
if (crop && selection.length === 1 && selection[0].id === crop.item.id if (crop && selection.length === 1 && selection[0].id === crop.item.id
&& ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) { && ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) {
@@ -487,6 +477,9 @@ export class CanvasEditor {
for (const item of this.items()) { for (const item of this.items()) {
item.node.classList.remove("selected", "multi-selected"); item.node.classList.remove("selected", "multi-selected");
item.node.setAttribute("aria-pressed", "false"); item.node.setAttribute("aria-pressed", "false");
for (const handle of [...item.node.children]) {
if (handle.dataset.corner) handle.remove();
}
} }
this.selected = items.length < 2 ? items[0] || null : { ...selectionBounds(items), items, this.selected = items.length < 2 ? items[0] || null : { ...selectionBounds(items), items,
id: items.map(item => item.id), index: items[0].index, page: items[0].page, surface: items[0].surface }; id: items.map(item => item.id), index: items[0].index, page: items[0].page, surface: items[0].surface };
@@ -503,6 +496,19 @@ export class CanvasEditor {
this.nodes.set(node, this.selected); this.nodes.set(node, this.selected);
this.place(this.selected); this.place(this.selected);
this.selected.surface.append(node); this.selected.surface.append(node);
} else if (items.length === 1) {
const item = items[0];
const handles = !canEditObject(item, "transform") ? [] : lineShape(item.shape) ? ["start", "end"]
: item.shape || item.oriented || canEditObject(item, "stretch") ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
: item.type === "TextObject" && canEditObject(item, "reflow") && canEditObject(item, "layoutKnown")
? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"];
for (const corner of handles) {
const handle = document.createElement("span");
handle.className = `edit-handle edit-${corner}`;
handle.dataset.corner = corner;
item.node.append(handle);
}
this.place(item);
} }
for (const item of items) { for (const item of items) {
item.node.classList.add("selected"); item.node.classList.add("selected");
+6 -8
View File
@@ -732,6 +732,10 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
info, err := currentEditor.PageObjectInfo(index)
if err != nil {
return nil, err
}
textBoxes := make(map[string]ofdgo.Box) textBoxes := make(map[string]ofdgo.Box)
for _, run := range text.Runs { for _, run := range text.Runs {
for _, box := range run.Boxes { for _, box := range run.Boxes {
@@ -770,10 +774,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
default: default:
continue continue
} }
capability, err := currentEditor.ObjectCapabilities(index, id) capability := info[id].Capabilities
if err != nil {
return nil, err
}
var box ofdgo.Box var box ofdgo.Box
var contours []ofdgo.ObjectContour var contours []ofdgo.ObjectContour
switch object.Type { switch object.Type {
@@ -786,10 +787,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
continue continue
} }
if box.W > 0 && box.H > 0 { if box.W > 0 && box.H > 0 {
position, err := currentEditor.ObjectPosition(index, id) position := info[id].Position
if err != nil {
return nil, err
}
item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container,
"capabilities": editorCapabilities(capability, object.Type)} "capabilities": editorCapabilities(capability, object.Type)}
editorAppearance(item, object, capability.Paint, editorPathScale(object.PathObject)) editorAppearance(item, object, capability.Paint, editorPathScale(object.PathObject))
+77 -22
View File
@@ -144,6 +144,53 @@ type ObjectPosition struct {
Count int Count int
} }
// ObjectInfo 对象的编辑能力及直接容器位置
type ObjectInfo struct {
Capabilities ObjectCapabilities
Position ObjectPosition
}
// PageObjectInfo 批量获取页面对象能力与位置,不重复扫描同一容器
// 每次按当前页面计算,变更和撤销后无需失效旧缓存
// 入参: page 页面索引
// 返回: map[string]ObjectInfo 对象标识与信息, error 错误信息
func (e *Editor) PageObjectInfo(page int) (map[string]ObjectInfo, error) {
content, err := e.page(page)
if err != nil {
return nil, err
}
result := make(map[string]ObjectInfo)
orderable := make(map[*editorXML]bool)
for i := range content.Content.Layer {
layer := &content.Content.Layer[i]
nodes := e.objectOrderNodes(page, layer)
groups := make(map[*editorXML][]string)
for _, object := range layer.Objects {
id := editorObjectID(object)
parent := e.objectOrderParent(nodes, id)
groups[parent] = append(groups[parent], id)
if id != "" {
result[id] = ObjectInfo{Capabilities: e.objectCapabilities(object, orderable)}
}
}
for parent, ids := range groups {
container := layer.ID
if parent != nil {
container = parent.attr("ID")
}
for index, id := range ids {
if id == "" {
continue
}
info := result[id]
info.Position = ObjectPosition{Layer: layer.ID, Container: container, Index: index, Count: len(ids)}
result[id] = info
}
}
}
return result, nil
}
// ObjectPosition 获取对象的直接容器及排序位置 // ObjectPosition 获取对象的直接容器及排序位置
// 入参: page 页面索引, id 对象标识 // 入参: page 页面索引, id 对象标识
// 返回: ObjectPosition 对象位置, error 错误信息 // 返回: ObjectPosition 对象位置, error 错误信息
@@ -170,36 +217,44 @@ func (e *Editor) ObjectPosition(page int, id string) (ObjectPosition, error) {
// 入参: page 页面索引, layer 图层, id 对象标识 // 入参: page 页面索引, layer 图层, id 对象标识
// 返回: *editorXML 原始容器, []int 对象索引 // 返回: *editorXML 原始容器, []int 对象索引
func (e *Editor) objectOrderIndexes(page int, layer *Layer, id string) (*editorXML, []int) { func (e *Editor) objectOrderIndexes(page int, layer *Layer, id string) (*editorXML, []int) {
var nodes map[string]*editorXML nodes := e.objectOrderNodes(page, layer)
var parent *editorXML parent := e.objectOrderParent(nodes, id)
if e.originalPage(page) {
source := e.source.pages[e.pages[page].ID]
if slices.ContainsFunc(source.original.Content.Layer, func(original Layer) bool { return original.ID == layer.ID }) {
nodes = source.nodes
}
}
if nodes != nil {
if node := nodes[id]; node != nil {
parent = node.parent
} else if origin := e.objectOrigin(id); origin != nil {
parent = origin.node.parent
}
}
var indexes []int var indexes []int
for i, object := range layer.Objects { for i, object := range layer.Objects {
node := nodes[editorObjectID(object)] if e.objectOrderParent(nodes, editorObjectID(object)) == parent {
if node == nil && nodes != nil {
if origin := e.objectOrigin(editorObjectID(object)); origin != nil {
node = origin.node
}
}
if node == nil && parent == nil || node != nil && node.parent == parent {
indexes = append(indexes, i) indexes = append(indexes, i)
} }
} }
return parent, indexes return parent, indexes
} }
// objectOrderNodes 获取原图层中的对象节点
// 入参: page 页面索引, layer 当前图层
// 返回: map[string]*editorXML 原节点映射
func (e *Editor) objectOrderNodes(page int, layer *Layer) map[string]*editorXML {
if e.originalPage(page) {
source := e.source.pages[e.pages[page].ID]
if slices.ContainsFunc(source.original.Content.Layer, func(original Layer) bool { return original.ID == layer.ID }) {
return source.nodes
}
}
return nil
}
// objectOrderParent 获取对象的原始直接容器,新对象沿用所在图层
// 入参: nodes 原节点映射, id 对象标识
// 返回: *editorXML 原始容器
func (e *Editor) objectOrderParent(nodes map[string]*editorXML, id string) *editorXML {
if nodes != nil {
if node := nodes[id]; node != nil {
return node.parent
} else if origin := e.objectOrigin(id); origin != nil {
return origin.node.parent
}
}
return nil
}
// OrderObjects 调整同一图层或页块内选区的绘制顺序,保留各组选中及未选中对象的相对顺序 // OrderObjects 调整同一图层或页块内选区的绘制顺序,保留各组选中及未选中对象的相对顺序
// 入参: page 页面索引, ids 对象标识, order 为up、down、top或bottom // 入参: page 页面索引, ids 对象标识, order 为up、down、top或bottom
// 返回: error 错误信息 // 返回: error 错误信息
+18 -7
View File
@@ -367,7 +367,14 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
if err != nil { if err != nil {
return ObjectCapabilities{}, err return ObjectCapabilities{}, err
} }
object := layer.Objects[index] return e.objectCapabilities(layer.Objects[index], make(map[*editorXML]bool)), nil
}
// objectCapabilities 检查对象能力,在同次遍历中复用原容器排序检查
// 入参: object 当前对象, orderable 原容器排序能力
// 返回: ObjectCapabilities 操作能力
func (e *Editor) objectCapabilities(object GraphicObject, orderable map[*editorXML]bool) ObjectCapabilities {
id := editorObjectID(object)
all := ObjectCapabilities{Update: true, Paint: object.Type == "TextObject" || object.Type == "PathObject", ReplaceFont: object.Type == "TextObject", Reflow: object.Type == "TextObject" && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0, LayoutKnown: object.Type == "TextObject" && (e.objectOrigin(id) == nil || object.TextObject.layout != nil), Transform: true, Arrange: true, Copy: true, Delete: true, Order: true} all := ObjectCapabilities{Update: true, Paint: object.Type == "TextObject" || object.Type == "PathObject", ReplaceFont: object.Type == "TextObject", Reflow: object.Type == "TextObject" && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0, LayoutKnown: object.Type == "TextObject" && (e.objectOrigin(id) == nil || object.TextObject.layout != nil), Transform: true, Arrange: true, Copy: true, Delete: true, Order: true}
all.ResetCrop = object.Type == "ImageObject" && object.state.crop != nil all.ResetCrop = object.Type == "ImageObject" && object.state.crop != nil
origin := e.objectOrigin(id) origin := e.objectOrigin(id)
@@ -377,22 +384,26 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
all.ReplaceImage = object.Type == "ImageObject" all.ReplaceImage = object.Type == "ImageObject"
all.CropImage = all.ReplaceImage all.CropImage = all.ReplaceImage
all.FitImage = all.ReplaceImage && axisAlignedMatrix(NewMatrix(object.ImageObject.CTM)) all.FitImage = all.ReplaceImage && axisAlignedMatrix(NewMatrix(object.ImageObject.CTM))
return all, nil return all
} }
node := origin.node node := origin.node
if !editorXMLTransformable(node) { if !editorXMLTransformable(node) {
return ObjectCapabilities{Reason: "object uses unsupported editing features", ReasonCode: EditUnsupportedObject}, nil return ObjectCapabilities{Reason: "object uses unsupported editing features", ReasonCode: EditUnsupportedObject}
} }
if origin.reason != nil && editReason(origin.reason) == EditUnsupportedStyle { if origin.reason != nil && editReason(origin.reason) == EditUnsupportedStyle {
return ObjectCapabilities{Reason: origin.reason.Error(), ReasonCode: EditUnsupportedStyle}, nil return ObjectCapabilities{Reason: origin.reason.Error(), ReasonCode: EditUnsupportedStyle}
} }
if !editorXMLContainersSupported(node) { if !editorXMLContainersSupported(node) {
return ObjectCapabilities{Reason: "object container uses unsupported editing features", ReasonCode: EditUnsupportedContainer}, nil return ObjectCapabilities{Reason: "object container uses unsupported editing features", ReasonCode: EditUnsupportedContainer}
} }
if err := validateEditorGeometry(object); err != nil { if err := validateEditorGeometry(object); err != nil {
return ObjectCapabilities{Reason: err.Error(), ReasonCode: EditInvalidObject}, nil return ObjectCapabilities{Reason: err.Error(), ReasonCode: EditInvalidObject}
} }
var checked bool
if all.Order, checked = orderable[node.parent]; !checked {
all.Order = editorContainerOrderable(node.parent) all.Order = editorContainerOrderable(node.parent)
orderable[node.parent] = all.Order
}
all.Ungroup = all.Order && e.compositeUngroupable(node) && (object.CompositeGraphicUnit.ResourceID != "" || len(object.CompositeGraphicUnit.Objects) != 0) all.Ungroup = all.Order && e.compositeUngroupable(node) && (object.CompositeGraphicUnit.ResourceID != "" || len(object.CompositeGraphicUnit.Objects) != 0)
all.Copy = editorXMLCopyable(node) && (origin.reason == nil || object.Type == "PathObject" && editReason(origin.reason) == EditUnsupportedColor) all.Copy = editorXMLCopyable(node) && (origin.reason == nil || object.Type == "PathObject" && editReason(origin.reason) == EditUnsupportedColor)
if origin.reason != nil { if origin.reason != nil {
@@ -422,7 +433,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
} }
} }
all.Stretch = all.Transform && e.objectStretchable(object, make(map[string]bool)) all.Stretch = all.Transform && e.objectStretchable(object, make(map[string]bool))
return all, nil return all
} }
// objectOrigin 获取原对象或其副本的保真来源 // objectOrigin 获取原对象或其副本的保真来源
+7
View File
@@ -353,6 +353,13 @@ func (p *fontUsagePage) decodeObjects(d *xml.Decoder, start xml.StartElement, de
switch node.Name.Local { switch node.Name.Local {
case "PageBlock": case "PageBlock":
return p.decodeObjects(d, node, defaults) return p.decodeObjects(d, node, defaults)
case "TextObject":
var obj TextObject
if err := d.DecodeElement(&obj, &node); err != nil {
return err
}
p.renderer.countObjectFonts(GraphicObject{Type: "TextObject", TextObject: obj}, p.usage, defaults, nil)
return nil
case "PathObject": case "PathObject":
var obj struct { var obj struct {
PathObject PathObject