"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()); } } }