533 lines
20 KiB
JavaScript
533 lines
20 KiB
JavaScript
"use strict";
|
|
|
|
import { debug, normalizeConceptTags } from "../cmap-utils.js";
|
|
|
|
let copiedConceptReferences = [];
|
|
|
|
export class CmapSelectionController {
|
|
constructor(editor) {
|
|
this.editor = editor;
|
|
}
|
|
|
|
get items() { return this.editor.items; }
|
|
get connectors() { return this.editor.connectors; }
|
|
get selectedItem() { return this.editor.selectedItem; }
|
|
set selectedItem(val) { this.editor.selectedItem = val; }
|
|
get selectedItems() { return this.editor.selectedItems; }
|
|
get selectedConnector() { return this.editor.selectedConnector; }
|
|
set selectedConnector(val) { this.editor.selectedConnector = val; }
|
|
|
|
selectItem(record, options = {}) {
|
|
if (!record) {
|
|
this.clearSelection();
|
|
return;
|
|
}
|
|
const additive = Boolean(options.additive);
|
|
const toggle = Boolean(options.toggle);
|
|
const groupRecords = record.groupId && options.expandGroup !== false ?
|
|
this.items.filter((item) => item.groupId === record.groupId && this.editor.isItemVisible(item)) :
|
|
[record];
|
|
debug("selectItem called", {
|
|
requestedId: record.id,
|
|
requestedKind: record.kind,
|
|
additive,
|
|
groupId: record.groupId,
|
|
previousIds: this.selectedAll().map((item) => item.id)
|
|
});
|
|
|
|
if (!additive) this.clearSelection(false);
|
|
const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item));
|
|
for (const item of groupRecords) {
|
|
if (remove) {
|
|
this.selectedItems.delete(item);
|
|
} else {
|
|
this.selectedItems.add(item);
|
|
}
|
|
}
|
|
|
|
this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record;
|
|
this.selectedConnector = null;
|
|
this.refreshSelectionDecoration();
|
|
debug("selection applied", {
|
|
selectedId: this.selectedItem ? this.selectedItem.id : null,
|
|
selectedIds: this.selectedAll().map((item) => item.id),
|
|
selectionCount: this.selectedItems.size
|
|
});
|
|
this.notifySelection();
|
|
}
|
|
|
|
refreshSelectionDecoration() {
|
|
for (const item of this.items) {
|
|
const element = item.node.element();
|
|
const selected = this.selectedItems.has(item);
|
|
if (element) {
|
|
element.classList.toggle("rw-cmap-selected", selected);
|
|
element.classList.toggle(
|
|
"rw-cmap-selected-primary", selected && item === this.selectedItem);
|
|
if (selected) {
|
|
element.setAttribute("aria-selected", "true");
|
|
item.node.toFront();
|
|
} else {
|
|
element.removeAttribute("aria-selected");
|
|
}
|
|
this.editor.removeHandles(element);
|
|
if (selected && item === this.selectedItem) this.editor.ensureHandles(item, element);
|
|
}
|
|
if (item.kind === "submap") this.editor.updateSubmapFrame(item);
|
|
}
|
|
this.editor.submaps.refreshGroupSelection();
|
|
}
|
|
|
|
selectConnector(record) {
|
|
this.clearSelection();
|
|
this.selectedConnector = record;
|
|
record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
|
|
record.link.redraw();
|
|
this.notifySelection();
|
|
}
|
|
|
|
clearSelection(notify = true) {
|
|
const clearedItemIds = this.selectedAll().map((item) => item.id);
|
|
const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
|
|
for (const item of this.selectedItems) {
|
|
const element = item.node.element();
|
|
if (element) {
|
|
element.classList.remove("rw-cmap-selected");
|
|
element.classList.remove("rw-cmap-selected-primary");
|
|
element.removeAttribute("aria-selected");
|
|
this.editor.removeHandles(element);
|
|
}
|
|
}
|
|
if (this.selectedConnector) {
|
|
const connector = this.selectedConnector;
|
|
connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
|
|
connector.link.redraw();
|
|
}
|
|
this.selectedItem = null;
|
|
this.selectedItems.clear();
|
|
this.selectedConnector = null;
|
|
if (clearedItemIds.length || clearedConnectorId) {
|
|
debug("selection cleared", { itemIds: clearedItemIds, connectorId: clearedConnectorId });
|
|
}
|
|
if (notify) this.notifySelection();
|
|
}
|
|
|
|
selected() {
|
|
return this.selectedItem;
|
|
}
|
|
|
|
selectedAll() {
|
|
return Array.from(this.selectedItems);
|
|
}
|
|
|
|
storeConceptReferences(records) {
|
|
if (!records.length) return 0;
|
|
const referencesById = new Map(records.map((source) =>
|
|
[source.conceptId, {
|
|
conceptId: source.conceptId,
|
|
kind: source.kind === "submap" ? "concept" : source.kind,
|
|
label: source.label,
|
|
synopsis: source.synopsis,
|
|
aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
|
|
tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
|
|
descriptionPageSlug: source.descriptionPageSlug,
|
|
pageSlug: source.pageSlug,
|
|
cmapSlug: source.cmapSlug,
|
|
externalUrl: source.externalUrl,
|
|
imageSource: source.imageSource,
|
|
width: Number(source.node.attr("width")),
|
|
height: Number(source.node.attr("height")),
|
|
backgroundColor: source.backgroundColor,
|
|
borderColor: source.borderColor,
|
|
textColor: source.textColor,
|
|
fontFamily: source.fontFamily,
|
|
fontSize: source.fontSize,
|
|
fontWeight: source.fontWeight,
|
|
fontStyle: source.fontStyle,
|
|
synopsisTextColor: source.synopsisTextColor,
|
|
synopsisFontFamily: source.synopsisFontFamily,
|
|
synopsisFontSize: source.synopsisFontSize,
|
|
synopsisFontWeight: source.synopsisFontWeight,
|
|
synopsisFontStyle: source.synopsisFontStyle
|
|
}]));
|
|
copiedConceptReferences = Array.from(referencesById.values());
|
|
return copiedConceptReferences.length;
|
|
}
|
|
|
|
copySelectionReferences() {
|
|
const selected = this.selectedAll()
|
|
.filter((item) => item.conceptId && item.kind !== "phrase");
|
|
const copied = this.storeConceptReferences(selected);
|
|
if (!copied) return 0;
|
|
this.notifySelection();
|
|
return copied;
|
|
}
|
|
|
|
canCutSelectionReferences() {
|
|
return this.selectedAll().some((item) =>
|
|
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
|
|
}
|
|
|
|
cutSelectionReferences() {
|
|
const cuttable = this.selectedAll().filter((item) =>
|
|
item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
|
|
if (!cuttable.length) return 0;
|
|
const copied = this.storeConceptReferences(cuttable);
|
|
if (!copied) return 0;
|
|
this.clearSelection(false);
|
|
for (const record of cuttable) this.selectedItems.add(record);
|
|
this.selectedItem = cuttable.at(-1) || null;
|
|
this.deleteSelection();
|
|
return copied;
|
|
}
|
|
|
|
canPasteConceptReferences() {
|
|
return copiedConceptReferences.length > 0;
|
|
}
|
|
|
|
pasteConceptReferences() {
|
|
const sources = copiedConceptReferences;
|
|
if (!sources.length) return [];
|
|
this.clearSelection(false);
|
|
const parentSubmap = this.editor.activeMapRoot || null;
|
|
const pasted = sources.map((source, index) => this.editor.addItem({
|
|
conceptId: source.conceptId,
|
|
kind: source.kind === "submap" ? "concept" : source.kind,
|
|
label: source.label,
|
|
synopsis: source.synopsis,
|
|
aspects: source.aspects,
|
|
tags: source.tags,
|
|
descriptionPageSlug: source.descriptionPageSlug,
|
|
pageSlug: source.pageSlug,
|
|
cmapSlug: source.cmapSlug,
|
|
externalUrl: source.externalUrl,
|
|
parentCmapLink: false,
|
|
imageSource: source.imageSource,
|
|
parentSubmap,
|
|
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
|
|
x: 120 + (index * 36),
|
|
y: 120 + (index * 36),
|
|
width: source.width,
|
|
height: source.height,
|
|
backgroundColor: source.backgroundColor,
|
|
borderColor: source.borderColor,
|
|
textColor: source.textColor,
|
|
fontFamily: source.fontFamily,
|
|
fontSize: source.fontSize,
|
|
fontWeight: source.fontWeight,
|
|
fontStyle: source.fontStyle,
|
|
synopsisTextColor: source.synopsisTextColor,
|
|
synopsisFontFamily: source.synopsisFontFamily,
|
|
synopsisFontSize: source.synopsisFontSize,
|
|
synopsisFontWeight: source.synopsisFontWeight,
|
|
synopsisFontStyle: source.synopsisFontStyle
|
|
}));
|
|
for (const record of pasted) this.selectedItems.add(record);
|
|
this.selectedItem = pasted.at(-1) || null;
|
|
this.refreshSelectionDecoration();
|
|
this.notifySelection();
|
|
return pasted;
|
|
}
|
|
|
|
selectAll() {
|
|
this.clearSelection(false);
|
|
for (const item of this.items) {
|
|
if (this.editor.isEffectiveItemVisible(item)) this.selectedItems.add(item);
|
|
}
|
|
this.selectedItem = this.selectedAll().at(-1) || null;
|
|
this.refreshSelectionDecoration();
|
|
this.notifySelection();
|
|
return this.selectedAll();
|
|
}
|
|
|
|
layoutSelectionRecords() {
|
|
return this.selectedAll().filter((item) => this.editor.isEffectiveItemVisible(item));
|
|
}
|
|
|
|
canLayoutSelection(command) {
|
|
const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
|
|
return this.layoutSelectionRecords().length >= minimum;
|
|
}
|
|
|
|
applySelectionLayout(command) {
|
|
const records = this.layoutSelectionRecords();
|
|
if (!this.canLayoutSelection(command)) return false;
|
|
const boxes = records.map((record) => ({
|
|
record,
|
|
x: Number(record.node.attr("x")),
|
|
y: Number(record.node.attr("y")),
|
|
width: Number(record.node.attr("width")),
|
|
height: Number(record.node.attr("height"))
|
|
}));
|
|
const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
|
|
const updates = new Map(boxes.map(({ record }) => [record, {}]));
|
|
const referenceRight = reference.x + reference.width;
|
|
const referenceCenter = reference.x + (reference.width / 2);
|
|
const referenceBottom = reference.y + reference.height;
|
|
const referenceMiddle = reference.y + (reference.height / 2);
|
|
|
|
if (["same-width", "same-size"].includes(command)) {
|
|
for (const box of boxes) updates.get(box.record).width = reference.width;
|
|
}
|
|
if (["same-height", "same-size"].includes(command)) {
|
|
for (const box of boxes) updates.get(box.record).height = reference.height;
|
|
}
|
|
if (command === "align-left") {
|
|
for (const box of boxes) updates.get(box.record).x = reference.x;
|
|
}
|
|
if (command === "align-right") {
|
|
for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
|
|
}
|
|
if (command === "align-center") {
|
|
for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
|
|
}
|
|
if (command === "align-top") {
|
|
for (const box of boxes) updates.get(box.record).y = reference.y;
|
|
}
|
|
if (command === "align-bottom") {
|
|
for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
|
|
}
|
|
if (command === "align-middle") {
|
|
for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
|
|
}
|
|
if (command === "distribute-horizontal") {
|
|
const ordered = [...boxes].sort((a, b) =>
|
|
(a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
|
|
String(a.record.id).localeCompare(String(b.record.id)));
|
|
const distributionLeft = ordered[0].x;
|
|
const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
|
|
const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
|
|
const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
|
|
let cursor = distributionLeft;
|
|
for (const box of ordered) {
|
|
updates.get(box.record).x = cursor;
|
|
cursor += box.width + gap;
|
|
}
|
|
}
|
|
if (command === "distribute-vertical") {
|
|
const ordered = [...boxes].sort((a, b) =>
|
|
(a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
|
|
String(a.record.id).localeCompare(String(b.record.id)));
|
|
const distributionTop = ordered[0].y;
|
|
const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
|
|
const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
|
|
const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
|
|
let cursor = distributionTop;
|
|
for (const box of ordered) {
|
|
updates.get(box.record).y = cursor;
|
|
cursor += box.height + gap;
|
|
}
|
|
}
|
|
|
|
if (!["same-width", "same-height", "same-size", "align-left", "align-right",
|
|
"align-center", "align-top", "align-bottom", "align-middle",
|
|
"distribute-horizontal", "distribute-vertical"].includes(command)) return false;
|
|
|
|
this.editor.scheduleHistoryCommit();
|
|
for (const record of records) {
|
|
const attributes = updates.get(record);
|
|
if (attributes.width !== undefined) {
|
|
record.width = attributes.width;
|
|
record.autoWidth = false;
|
|
}
|
|
if (attributes.height !== undefined) {
|
|
record.height = attributes.height;
|
|
record.autoHeight = false;
|
|
}
|
|
record.node.attr(attributes);
|
|
record.node.redraw();
|
|
}
|
|
this.editor.saveCurrentContextLayout();
|
|
this.editor.refreshConnectorGeometry();
|
|
for (const submap of this.items
|
|
.filter((item) => item.kind === "submap")
|
|
.sort((a, b) => b.submapDepth - a.submapDepth)) {
|
|
this.editor.updateSubmapFrame(submap);
|
|
}
|
|
this.refreshSelectionDecoration();
|
|
debug("selection layout applied", {
|
|
command,
|
|
referenceItemId: reference.record.id,
|
|
itemIds: records.map((record) => record.id)
|
|
});
|
|
return true;
|
|
}
|
|
|
|
canGroupSelection() {
|
|
const selected = this.selectedAll();
|
|
return selected.length >= 2 &&
|
|
selected.every((item) => item.parentSubmap === selected[0].parentSubmap);
|
|
}
|
|
|
|
groupSelection(options = {}) {
|
|
const selected = this.selectedAll();
|
|
if (!this.canGroupSelection()) return false;
|
|
const parentSubmap = selected[0].parentSubmap;
|
|
const left = Math.min(...selected.map((item) => Number(item.node.attr("x"))));
|
|
const top = Math.min(...selected.map((item) => Number(item.node.attr("y"))));
|
|
const label = String(options.label || "Sub-conceptmap").trim() || "Sub-conceptmap";
|
|
const submap = this.editor.addItem({
|
|
...options,
|
|
kind: "submap",
|
|
label,
|
|
childMap: options.childMap || label,
|
|
synopsis: options.synopsis || "Grouped sub-concept map.",
|
|
parentSubmap,
|
|
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
|
|
x: (options.x === undefined || options.x === null) ? left : Number(options.x),
|
|
y: (options.y === undefined || options.y === null) ? Math.max(20, top - 105) : Number(options.y),
|
|
backgroundColor: options.backgroundColor || "#edf7e8",
|
|
borderColor: options.borderColor || "#57834a"
|
|
});
|
|
|
|
submap.expanded = true;
|
|
submap.submapInitialized = true;
|
|
for (const item of selected) {
|
|
item.groupId = null;
|
|
item.parentSubmap = submap;
|
|
this.editor.updateSubmapDepth(item, submap.submapDepth + 1);
|
|
}
|
|
this.editor.reconcilePhraseMembership();
|
|
this.editor.refreshConceptMapReferences();
|
|
this.editor.applyCurrentContextLayout();
|
|
this.selectItem(submap);
|
|
debug("selection grouped as submap", {
|
|
submapId: submap.id,
|
|
itemIds: selected.map((item) => item.id)
|
|
});
|
|
return submap;
|
|
}
|
|
|
|
canUngroupSelection() {
|
|
return this.selectedAll().some((item) =>
|
|
Boolean(item.groupId) ||
|
|
(item.kind === "submap" && !item.separateMap) ||
|
|
Boolean(item.parentSubmap && item.parentSubmap !== this.editor.activeMapRoot));
|
|
}
|
|
|
|
ungroupSelection() {
|
|
const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean));
|
|
const affected = this.items.filter((item) => groupIds.has(item.groupId));
|
|
for (const item of affected) item.groupId = null;
|
|
|
|
const selected = this.selectedAll();
|
|
const selectedSubmaps = new Set(selected.filter((item) =>
|
|
item.kind === "submap" && !item.separateMap));
|
|
const liftedChildren = new Set();
|
|
for (const submap of selectedSubmaps) {
|
|
const parent = submap.parentSubmap;
|
|
const children = this.items.filter((item) => item.parentSubmap === submap);
|
|
for (const child of children) {
|
|
liftedChildren.add(child);
|
|
child.parentSubmap = parent;
|
|
this.editor.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0);
|
|
}
|
|
submap.expanded = false;
|
|
submap.submapInitialized = false;
|
|
submap.childMap = null;
|
|
this.editor.updateItem(submap, { kind: "concept" });
|
|
affected.push(submap, ...children);
|
|
}
|
|
|
|
for (const item of selected) {
|
|
if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap ||
|
|
item.parentSubmap === this.editor.activeMapRoot) continue;
|
|
const parent = item.parentSubmap.parentSubmap;
|
|
item.parentSubmap = parent;
|
|
this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
|
|
affected.push(item);
|
|
}
|
|
|
|
if (!affected.length) return false;
|
|
this.editor.reconcilePhraseMembership();
|
|
this.editor.refreshConceptMapReferences();
|
|
this.editor.refreshSubmapVisibility();
|
|
this.refreshSelectionDecoration();
|
|
debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) });
|
|
this.notifySelection();
|
|
this.editor.scheduleHistoryCommit();
|
|
return true;
|
|
}
|
|
|
|
deleteSelection() {
|
|
const records = new Set(this.selectedAll().filter((item) => item !== this.editor.activeMapRoot));
|
|
for (const record of Array.from(records)) {
|
|
if (record.kind === "submap") {
|
|
for (const item of this.items) {
|
|
if (this.editor.isDescendantOf(item, record)) records.add(item);
|
|
}
|
|
}
|
|
}
|
|
const connectors = new Set(this.connectors.filter((connector) =>
|
|
connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target)));
|
|
const affectedPhrases = new Set();
|
|
for (const connector of connectors) {
|
|
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
|
|
affectedPhrases.add(connector.source);
|
|
}
|
|
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
|
|
affectedPhrases.add(connector.target);
|
|
}
|
|
}
|
|
let foundOrphan = true;
|
|
while (foundOrphan) {
|
|
foundOrphan = false;
|
|
const remainingConnectors = this.connectors.filter((connector) =>
|
|
!connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target));
|
|
for (const phrase of Array.from(affectedPhrases).filter((item) => !records.has(item))) {
|
|
const hasSource = remainingConnectors.some((connector) => connector.target === phrase);
|
|
const hasTarget = remainingConnectors.some((connector) => connector.source === phrase);
|
|
if (hasSource && hasTarget) continue;
|
|
records.add(phrase);
|
|
for (const connector of this.connectors) {
|
|
if (connector.source !== phrase && connector.target !== phrase) continue;
|
|
connectors.add(connector);
|
|
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
|
|
affectedPhrases.add(connector.source);
|
|
}
|
|
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
|
|
affectedPhrases.add(connector.target);
|
|
}
|
|
}
|
|
foundOrphan = true;
|
|
}
|
|
}
|
|
if (!records.size && !connectors.size) return false;
|
|
|
|
this.clearSelection(false);
|
|
for (const connector of connectors) {
|
|
connector.link.remove();
|
|
this.editor.model.conceptMap.removeConnector(connector.id);
|
|
}
|
|
this.editor.connectors = this.connectors.filter((connector) => !connectors.has(connector));
|
|
for (const record of records) {
|
|
if (record.mapReference && record.mapReference.id) this.editor.conceptMaps.delete(record.mapReference.id);
|
|
record.node.remove();
|
|
this.editor.model.conceptMap.removeItem(record.id);
|
|
}
|
|
if (records.size && this.editor.unresolvedConnectors.length) {
|
|
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
|
|
this.editor.unresolvedConnectors = this.editor.unresolvedConnectors.filter((connector) =>
|
|
!deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId)));
|
|
}
|
|
this.editor.items = this.items.filter((item) => !records.has(item));
|
|
this.editor.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId));
|
|
this.editor.reconcilePhraseMembership();
|
|
this.editor.refreshConceptMapReferences();
|
|
this.editor.refreshSubmapVisibility();
|
|
this.notifySelection();
|
|
debug("selection deleted", {
|
|
itemIds: Array.from(records).map((item) => item.id),
|
|
connectorIds: Array.from(connectors).map((connector) => connector.id)
|
|
});
|
|
this.editor.scheduleHistoryCommit();
|
|
return true;
|
|
}
|
|
|
|
notifySelection() {
|
|
if (this.editor.onSelectionChange) {
|
|
this.editor.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll());
|
|
}
|
|
}
|
|
}
|