big cmap refactoring
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { DiagramComponent } from "./diagram-component.js";
|
||||
|
||||
/**
|
||||
* Render a nested group frame around diagram components.
|
||||
*
|
||||
* A group is a view-level container, not a domain model. It owns membership,
|
||||
* expanded state and frame geometry while DiagramEngine continues to own the
|
||||
* lifetime of nodes and links. Application code can map a wiki submap or any
|
||||
* other grouping concept onto this generic abstraction.
|
||||
*/
|
||||
export class DiagramGroup {
|
||||
/**
|
||||
* goal : Create an empty diagram group attached to one engine.
|
||||
* pre : engine is a DiagramEngine and options contains only presentation data.
|
||||
* post : The group has no members and has not yet rendered a frame.
|
||||
* result : A DiagramGroup instance.
|
||||
* internals : Membership is kept as a Set so nested groups and repeated add
|
||||
* operations remain deterministic; bounds are calculated from member handles.
|
||||
*/
|
||||
constructor(engine, options = {}) {
|
||||
if (!engine) throw new TypeError("A diagram engine is required");
|
||||
this.engine = engine;
|
||||
this.label = String(options.label || "");
|
||||
this.className = String(options.className || "cmap-group-frame");
|
||||
this.backgroundColor = options.backgroundColor || "transparent";
|
||||
this.borderColor = options.borderColor || "#5d6d7e";
|
||||
this.padding = Number.isFinite(Number(options.padding)) ? Number(options.padding) : 24;
|
||||
this.depth = Number.isFinite(Number(options.depth)) ? Number(options.depth) : 0;
|
||||
this.expanded = options.expanded !== false;
|
||||
this.manageVisibility = options.manageVisibility !== false;
|
||||
this.members = new Set();
|
||||
this.elementValue = null;
|
||||
this.onToggleHandler = null;
|
||||
this.onPointerDownHandler = options.onPointerDown || null;
|
||||
this.onDoubleClickHandler = options.onDoubleClick || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Add one node or nested group to this group.
|
||||
* pre : member belongs to the same DiagramEngine.
|
||||
* post : The member contributes to group bounds and rendering.
|
||||
* result : This group, for fluent setup.
|
||||
*/
|
||||
add(member) {
|
||||
if (!(member instanceof DiagramComponent) && !(member instanceof DiagramGroup)) {
|
||||
throw new TypeError("A diagram group member is required");
|
||||
}
|
||||
if (member.engine !== this.engine) throw new TypeError("Group member belongs to another engine");
|
||||
this.members.add(member);
|
||||
this.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove a member and update the frame. */
|
||||
remove(member) {
|
||||
this.members.delete(member);
|
||||
this.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Register the callback invoked when the generic frame toggle is clicked. */
|
||||
onToggle(handler) {
|
||||
if (handler !== null && handler !== undefined && typeof handler !== "function") {
|
||||
throw new TypeError("Invalid group toggle handler");
|
||||
}
|
||||
this.onToggleHandler = handler || null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Set the visual appearance of the group frame.
|
||||
* pre : values contains optional CSS colour values.
|
||||
* post : The next redraw uses the supplied background and border colours.
|
||||
* result : This group, for fluent setup.
|
||||
* internals : Values are kept on the group and applied as CSS custom
|
||||
* properties in redraw(), allowing application-specific frame styles.
|
||||
*/
|
||||
setAppearance(values = {}) {
|
||||
if (values.backgroundColor !== undefined) this.backgroundColor = String(values.backgroundColor);
|
||||
if (values.borderColor !== undefined) this.borderColor = String(values.borderColor);
|
||||
if (values.label !== undefined) this.label = String(values.label);
|
||||
this.redraw();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Change whether group contents are shown.
|
||||
* pre : expanded is boolean-coercible.
|
||||
* post : The frame and all member components reflect the new state.
|
||||
* result : The resulting expanded state.
|
||||
* internals : A collapsed group hides direct members; nested groups redraw
|
||||
* themselves so their own frames disappear with the enclosing group.
|
||||
*/
|
||||
setExpanded(expanded) {
|
||||
this.expanded = Boolean(expanded);
|
||||
if (this.manageVisibility) {
|
||||
for (const member of this.members) {
|
||||
if (member instanceof DiagramGroup) member.setExpanded(this.expanded && member.expanded);
|
||||
else member.visible(this.expanded);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
return this.expanded;
|
||||
}
|
||||
|
||||
/** Return the union bounds of visible member nodes and nested groups. */
|
||||
bounds() {
|
||||
const members = [...this.members]
|
||||
.map((member) => member instanceof DiagramGroup ? member.bounds() :
|
||||
(member.visible() ? this.componentBounds(member) : null))
|
||||
.filter(Boolean);
|
||||
if (!members.length) return null;
|
||||
return {
|
||||
left: Math.min(...members.map((bounds) => bounds.left)) - this.padding,
|
||||
top: Math.min(...members.map((bounds) => bounds.top)) - this.padding,
|
||||
right: Math.max(...members.map((bounds) => bounds.right)) + this.padding,
|
||||
bottom: Math.max(...members.map((bounds) => bounds.bottom)) + this.padding
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the DOM frame, if the group currently has one. */
|
||||
element() {
|
||||
return this.elementValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render or remove the generic frame according to membership and state.
|
||||
* The engine calls this after component redraws; applications may call it
|
||||
* directly after changing group presentation or membership.
|
||||
*/
|
||||
redraw() {
|
||||
const surface = this.engine.surfaceElement();
|
||||
const bounds = this.expanded && this.bounds();
|
||||
if (!surface || !bounds) {
|
||||
this.removeElement();
|
||||
return this;
|
||||
}
|
||||
if (!this.elementValue) {
|
||||
const frame = document.createElement("div");
|
||||
frame.className = this.className;
|
||||
frame.setAttribute("role", "group");
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
const dragEdge = document.createElement("div");
|
||||
dragEdge.className = `${this.className}-drag-edge ${this.className}-drag-edge-${side}`;
|
||||
if (this.onPointerDownHandler) dragEdge.addEventListener("pointerdown", this.onPointerDownHandler);
|
||||
if (this.onDoubleClickHandler) dragEdge.addEventListener("dblclick", this.onDoubleClickHandler);
|
||||
frame.append(dragEdge);
|
||||
}
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = `${this.className}-toggle`;
|
||||
toggle.addEventListener("click", () => {
|
||||
this.setExpanded(!this.expanded);
|
||||
if (this.onToggleHandler) this.onToggleHandler(this, this.expanded);
|
||||
});
|
||||
frame.append(toggle);
|
||||
surface.prepend(frame);
|
||||
this.elementValue = frame;
|
||||
}
|
||||
const toggle = this.elementValue.querySelector(`.${this.className}-toggle`);
|
||||
if (toggle) {
|
||||
toggle.textContent = this.expanded ? "-" : "+";
|
||||
toggle.setAttribute("aria-expanded", String(this.expanded));
|
||||
toggle.setAttribute("aria-label", this.expanded ? "Collapse group" : "Expand group");
|
||||
}
|
||||
this.elementValue.setAttribute("aria-label", this.label);
|
||||
Object.assign(this.elementValue.style, {
|
||||
left: `${bounds.left}px`,
|
||||
top: `${bounds.top}px`,
|
||||
width: `${bounds.right - bounds.left}px`,
|
||||
height: `${bounds.bottom - bounds.top}px`,
|
||||
zIndex: String(this.depth)
|
||||
});
|
||||
this.elementValue.style.setProperty("--rw-cmap-submap-background", this.backgroundColor);
|
||||
this.elementValue.style.setProperty("--rw-cmap-submap-border", this.borderColor);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove the frame and all references owned by this group. */
|
||||
destroy() {
|
||||
for (const member of this.members) {
|
||||
if (member instanceof DiagramGroup) member.destroy();
|
||||
}
|
||||
this.members.clear();
|
||||
this.removeElement();
|
||||
}
|
||||
|
||||
componentBounds(component) {
|
||||
const attributes = component.attr();
|
||||
const x = Number(attributes.x);
|
||||
const y = Number(attributes.y);
|
||||
const width = Number(attributes.width);
|
||||
const height = Number(attributes.height);
|
||||
if (![x, y, width, height].every(Number.isFinite)) return null;
|
||||
return { left: x, top: y, right: x + width, bottom: y + height };
|
||||
}
|
||||
|
||||
removeElement() {
|
||||
if (this.elementValue) {
|
||||
this.elementValue.remove();
|
||||
this.elementValue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user