storymapper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storymap UI-Shell — Custom-Dialog-System.
|
|
3
|
+
*
|
|
4
|
+
* Portiert von cmapper (concept_map.html UIShell-Section). Stellt die
|
|
5
|
+
* Standard-Bausteine bereit, mit denen alle anderen Renderer (Story-Map,
|
|
6
|
+
* Kanban, Sidebar) interagieren — anstelle von Browser-prompt/confirm/alert.
|
|
7
|
+
*
|
|
8
|
+
* API (alle UMD-exportiert auf window.STORYMAP.uiShell):
|
|
9
|
+
* showModal({title, sub?, bodyHTML?, actions, onMount?})
|
|
10
|
+
* showSettingsPopover({anchorEl, title?, bodyHTML?, onMount?, onClose?})
|
|
11
|
+
* flashStatus(msg, {kind?, spinner?}) → { dismiss() }
|
|
12
|
+
* wireDebounced(input, fn, ms?)
|
|
13
|
+
* bindLongPress(btn, {onShort, onLong, ms?})
|
|
14
|
+
* mountMenuBar(barEl, MENUS) → { destroy() }
|
|
15
|
+
*
|
|
16
|
+
* Alle UI-Konstanten im CONSTANTS-Block am Anfang — keine Magic Numbers
|
|
17
|
+
* im Code. Tests referenzieren CONSTANTS, nicht Literalwerte.
|
|
18
|
+
*/
|
|
19
|
+
(function (root, factory) {
|
|
20
|
+
if (typeof module === "object" && module.exports) module.exports = factory();
|
|
21
|
+
else (root.STORYMAP = root.STORYMAP || {}).uiShell = factory();
|
|
22
|
+
}(typeof self !== "undefined" ? self : this, function () {
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
const CONSTANTS = {
|
|
26
|
+
LONGPRESS_MS_DEFAULT: 400,
|
|
27
|
+
DEBOUNCE_MS_DEFAULT: 250,
|
|
28
|
+
FLASH_TIMEOUT_MS: 1800,
|
|
29
|
+
POPOVER_VIEWPORT_MARGIN_PX: 4,
|
|
30
|
+
POPOVER_ANCHOR_GAP_PX: 4,
|
|
31
|
+
POPOVER_Z_INDEX: 50,
|
|
32
|
+
MODAL_Z_INDEX: 60,
|
|
33
|
+
MENU_Z_INDEX: 70
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---- DOM helpers ------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function el(tag, props, children) {
|
|
39
|
+
const e = document.createElement(tag);
|
|
40
|
+
if (props) {
|
|
41
|
+
for (const k of Object.keys(props)) {
|
|
42
|
+
if (k === "class") e.className = props[k];
|
|
43
|
+
else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
|
|
44
|
+
else if (k === "style") Object.assign(e.style, props[k]);
|
|
45
|
+
else if (k === "text") e.textContent = props[k];
|
|
46
|
+
else if (k === "html") e.innerHTML = props[k];
|
|
47
|
+
else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
|
48
|
+
else e.setAttribute(k, props[k]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
52
|
+
return e;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- showModal --------------------------------------------------------
|
|
56
|
+
//
|
|
57
|
+
// Strikt am cmapper-Vorbild orientiert (concept_map.html Z. 9178). Single
|
|
58
|
+
// modal-host (#modal-host) — falls bereits ein Modal offen ist, wird es
|
|
59
|
+
// ersetzt. innerHTML-Aufbau: h2 + .modal-sub + bodyHTML + .modal-actions
|
|
60
|
+
// in dieser Reihenfolge. Action-onClick-Returnwert false hält Modal offen
|
|
61
|
+
// (await für async-Aktionen).
|
|
62
|
+
|
|
63
|
+
function escapeHtml(s) {
|
|
64
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => (
|
|
65
|
+
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function showModal(opts) {
|
|
70
|
+
let host = document.getElementById("modal-host");
|
|
71
|
+
if (!host) {
|
|
72
|
+
// Fallback: kein dedizierter Host im HTML → temporären anlegen,
|
|
73
|
+
// damit das Pattern auch in Tests / einfachen Pages funktioniert.
|
|
74
|
+
host = document.createElement("div");
|
|
75
|
+
host.id = "modal-host";
|
|
76
|
+
document.body.appendChild(host);
|
|
77
|
+
}
|
|
78
|
+
host.innerHTML = "";
|
|
79
|
+
|
|
80
|
+
const overlay = document.createElement("div");
|
|
81
|
+
overlay.className = "modal-overlay";
|
|
82
|
+
const modal = document.createElement("div");
|
|
83
|
+
modal.className = "modal";
|
|
84
|
+
|
|
85
|
+
const acts = opts.actions || [];
|
|
86
|
+
const actionsHtml = acts.length
|
|
87
|
+
? '<div class="modal-actions">' + acts.map((a, i) => {
|
|
88
|
+
const cls = "btn" + (a.primary ? " primary" : "") + (a.destructive ? " destructive" : "")
|
|
89
|
+
+ (a.className ? " " + escapeHtml(a.className) : "");
|
|
90
|
+
return '<button class="' + cls + '" data-act="' + i + '">' + escapeHtml(a.label) + '</button>';
|
|
91
|
+
}).join("") + '</div>'
|
|
92
|
+
: "";
|
|
93
|
+
|
|
94
|
+
modal.innerHTML =
|
|
95
|
+
'<h2>' + escapeHtml(opts.title || "") + '</h2>' +
|
|
96
|
+
(opts.sub ? '<div class="modal-sub">' + escapeHtml(opts.sub) + '</div>' : "") +
|
|
97
|
+
(opts.bodyHTML || "") +
|
|
98
|
+
actionsHtml;
|
|
99
|
+
|
|
100
|
+
overlay.appendChild(modal);
|
|
101
|
+
host.appendChild(overlay);
|
|
102
|
+
|
|
103
|
+
function close() {
|
|
104
|
+
host.innerHTML = "";
|
|
105
|
+
document.removeEventListener("keydown", escHandler, true);
|
|
106
|
+
}
|
|
107
|
+
function escHandler(ev) {
|
|
108
|
+
if (ev.key === "Escape") { ev.stopPropagation(); close(); }
|
|
109
|
+
}
|
|
110
|
+
document.addEventListener("keydown", escHandler, true);
|
|
111
|
+
overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
|
|
112
|
+
|
|
113
|
+
acts.forEach((a, i) => {
|
|
114
|
+
const btn = modal.querySelector('[data-act="' + i + '"]');
|
|
115
|
+
if (!btn) return;
|
|
116
|
+
btn.addEventListener("click", async () => {
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
if (typeof a.onClick === "function") result = await a.onClick(modal, close);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error("modal action error:", err);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (result !== false) close();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (typeof opts.onMount === "function") opts.onMount(modal, close);
|
|
129
|
+
return { modal, close };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- showSettingsPopover ---------------------------------------------
|
|
133
|
+
|
|
134
|
+
let _currentPopover = null;
|
|
135
|
+
|
|
136
|
+
function _closePopover(cause) {
|
|
137
|
+
if (!_currentPopover) return;
|
|
138
|
+
const c = _currentPopover;
|
|
139
|
+
_currentPopover = null;
|
|
140
|
+
document.removeEventListener("keydown", c._esc, true);
|
|
141
|
+
document.removeEventListener("mousedown", c._outside, true);
|
|
142
|
+
if (c.node.parentNode) c.node.parentNode.removeChild(c.node);
|
|
143
|
+
if (typeof c.onClose === "function") c.onClose(cause);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _positionPopover(node, anchorEl) {
|
|
147
|
+
const m = CONSTANTS.POPOVER_VIEWPORT_MARGIN_PX;
|
|
148
|
+
const gap = CONSTANTS.POPOVER_ANCHOR_GAP_PX;
|
|
149
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
150
|
+
const ph = node.offsetHeight;
|
|
151
|
+
const pw = node.offsetWidth;
|
|
152
|
+
const vh = window.innerHeight || 800;
|
|
153
|
+
const vw = window.innerWidth || 1200;
|
|
154
|
+
let top = rect.bottom + gap;
|
|
155
|
+
if (top + ph + m > vh) top = Math.max(m, rect.top - ph - gap);
|
|
156
|
+
let left = rect.right - pw;
|
|
157
|
+
if (left < m) left = m;
|
|
158
|
+
if (left + pw + m > vw) left = vw - pw - m;
|
|
159
|
+
node.style.top = top + "px";
|
|
160
|
+
node.style.left = left + "px";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- showContextMenu (SM-30) -----------------------------------------
|
|
164
|
+
//
|
|
165
|
+
// Cursor-anchored popover with a vertical list of items. Reuses the same
|
|
166
|
+
// singleton + outside/Esc cleanup as showSettingsPopover, but positions at
|
|
167
|
+
// (clientX, clientY) — clamped to viewport — instead of relative to a DOM
|
|
168
|
+
// anchor. Each item is `{label, onClick, disabled?}`; clicking an enabled
|
|
169
|
+
// item fires the callback then closes the menu.
|
|
170
|
+
//
|
|
171
|
+
// showContextMenu({
|
|
172
|
+
// clientX, clientY,
|
|
173
|
+
// items: [{label: "Top of Backlog", onClick: () => ...}, ...],
|
|
174
|
+
// onClose?: (cause) => ...
|
|
175
|
+
// })
|
|
176
|
+
//
|
|
177
|
+
// Returns `{close()}`. Callers should `event.preventDefault()` on the
|
|
178
|
+
// triggering contextmenu event themselves to suppress the browser menu.
|
|
179
|
+
function _positionContextMenu(node, clientX, clientY) {
|
|
180
|
+
const m = CONSTANTS.POPOVER_VIEWPORT_MARGIN_PX;
|
|
181
|
+
const ph = node.offsetHeight;
|
|
182
|
+
const pw = node.offsetWidth;
|
|
183
|
+
const vh = window.innerHeight || 800;
|
|
184
|
+
const vw = window.innerWidth || 1200;
|
|
185
|
+
let top = clientY;
|
|
186
|
+
let left = clientX;
|
|
187
|
+
if (top + ph + m > vh) top = Math.max(m, vh - ph - m);
|
|
188
|
+
if (left + pw + m > vw) left = Math.max(m, vw - pw - m);
|
|
189
|
+
node.style.top = top + "px";
|
|
190
|
+
node.style.left = left + "px";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function showContextMenu(opts) {
|
|
194
|
+
if (_currentPopover) _closePopover("explicit");
|
|
195
|
+
const node = el("div", {
|
|
196
|
+
class: "context-menu",
|
|
197
|
+
style: { zIndex: String(CONSTANTS.POPOVER_Z_INDEX) }
|
|
198
|
+
});
|
|
199
|
+
const list = el("div", { class: "context-menu-list" });
|
|
200
|
+
const items = Array.isArray(opts.items) ? opts.items : [];
|
|
201
|
+
for (const item of items) {
|
|
202
|
+
const disabled = typeof item.disabled === "function" ? !!item.disabled() : !!item.disabled;
|
|
203
|
+
const btn = el("button", {
|
|
204
|
+
class: "context-menu-item" + (disabled ? " context-menu-item-disabled" : ""),
|
|
205
|
+
type: "button",
|
|
206
|
+
text: item.label
|
|
207
|
+
});
|
|
208
|
+
if (!disabled) {
|
|
209
|
+
btn.addEventListener("click", (ev) => {
|
|
210
|
+
ev.stopPropagation();
|
|
211
|
+
_closePopover("item");
|
|
212
|
+
try { item.onClick(ev); } catch (e) { /* surface via flashStatus is caller's job */ throw e; }
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
btn.setAttribute("disabled", "disabled");
|
|
216
|
+
}
|
|
217
|
+
list.appendChild(btn);
|
|
218
|
+
}
|
|
219
|
+
node.appendChild(list);
|
|
220
|
+
document.body.appendChild(node);
|
|
221
|
+
_positionContextMenu(node, opts.clientX || 0, opts.clientY || 0);
|
|
222
|
+
const handle = {
|
|
223
|
+
node,
|
|
224
|
+
onClose: opts.onClose,
|
|
225
|
+
_esc(ev) { if (ev.key === "Escape") { ev.stopPropagation(); _closePopover("escape"); } },
|
|
226
|
+
_outside(ev) { if (!node.contains(ev.target)) _closePopover("outside"); }
|
|
227
|
+
};
|
|
228
|
+
document.addEventListener("keydown", handle._esc, true);
|
|
229
|
+
document.addEventListener("mousedown", handle._outside, true);
|
|
230
|
+
_currentPopover = handle;
|
|
231
|
+
return { close: () => _closePopover("explicit") };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function showSettingsPopover(opts) {
|
|
235
|
+
if (_currentPopover) _closePopover("explicit");
|
|
236
|
+
|
|
237
|
+
const node = el("div", { class: "settings-popover", style: { zIndex: String(CONSTANTS.POPOVER_Z_INDEX) } });
|
|
238
|
+
if (opts.title) node.appendChild(el("div", { class: "sp-title", text: opts.title }));
|
|
239
|
+
const body = el("div", { class: "sp-body" });
|
|
240
|
+
if (opts.bodyHTML) body.innerHTML = opts.bodyHTML;
|
|
241
|
+
node.appendChild(body);
|
|
242
|
+
|
|
243
|
+
document.body.appendChild(node);
|
|
244
|
+
_positionPopover(node, opts.anchorEl);
|
|
245
|
+
|
|
246
|
+
const handle = {
|
|
247
|
+
node,
|
|
248
|
+
onClose: opts.onClose,
|
|
249
|
+
_esc(ev) { if (ev.key === "Escape") { ev.stopPropagation(); _closePopover("escape"); } },
|
|
250
|
+
_outside(ev) {
|
|
251
|
+
if (!node.contains(ev.target) && !opts.anchorEl.contains(ev.target)) _closePopover("outside");
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
document.addEventListener("keydown", handle._esc, true);
|
|
255
|
+
document.addEventListener("mousedown", handle._outside, true);
|
|
256
|
+
_currentPopover = handle;
|
|
257
|
+
|
|
258
|
+
if (typeof opts.onMount === "function") opts.onMount(node, () => _closePopover("explicit"));
|
|
259
|
+
return { close: () => _closePopover("explicit") };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- flashStatus ------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
let _flashTimer = null;
|
|
265
|
+
|
|
266
|
+
function _removeFlash() {
|
|
267
|
+
const cur = document.querySelector(".flash");
|
|
268
|
+
if (cur) cur.parentNode.removeChild(cur);
|
|
269
|
+
if (_flashTimer) { clearTimeout(_flashTimer); _flashTimer = null; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function flashStatus(msg, opts) {
|
|
273
|
+
opts = opts || {};
|
|
274
|
+
_removeFlash();
|
|
275
|
+
const flash = el("div", { class: "flash" + (opts.kind ? " flash-" + opts.kind : "") });
|
|
276
|
+
if (opts.spinner) flash.appendChild(el("span", { class: "flash-spinner" }));
|
|
277
|
+
flash.appendChild(document.createTextNode(msg || ""));
|
|
278
|
+
document.body.appendChild(flash);
|
|
279
|
+
|
|
280
|
+
let dismissed = false;
|
|
281
|
+
function dismiss() {
|
|
282
|
+
if (dismissed) return;
|
|
283
|
+
dismissed = true;
|
|
284
|
+
if (flash.parentNode) flash.parentNode.removeChild(flash);
|
|
285
|
+
if (_flashTimer) { clearTimeout(_flashTimer); _flashTimer = null; }
|
|
286
|
+
}
|
|
287
|
+
if (!opts.spinner) {
|
|
288
|
+
_flashTimer = setTimeout(dismiss, CONSTANTS.FLASH_TIMEOUT_MS);
|
|
289
|
+
}
|
|
290
|
+
return { dismiss };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- wireDebounced ---------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function wireDebounced(input, fn, ms) {
|
|
296
|
+
const delay = Number.isFinite(ms) ? ms : CONSTANTS.DEBOUNCE_MS_DEFAULT;
|
|
297
|
+
let t = null;
|
|
298
|
+
input.addEventListener("input", () => {
|
|
299
|
+
if (t) clearTimeout(t);
|
|
300
|
+
t = setTimeout(() => { t = null; fn(input.value); }, delay);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- bindLongPress ---------------------------------------------------
|
|
305
|
+
|
|
306
|
+
function bindLongPress(btn, opts) {
|
|
307
|
+
const ms = (opts && Number.isFinite(opts.ms)) ? opts.ms : CONSTANTS.LONGPRESS_MS_DEFAULT;
|
|
308
|
+
let timer = null;
|
|
309
|
+
let didLong = false;
|
|
310
|
+
|
|
311
|
+
btn.addEventListener("pointerdown", () => {
|
|
312
|
+
didLong = false;
|
|
313
|
+
if (timer) clearTimeout(timer);
|
|
314
|
+
timer = setTimeout(() => {
|
|
315
|
+
didLong = true;
|
|
316
|
+
timer = null;
|
|
317
|
+
if (opts && typeof opts.onLong === "function") opts.onLong();
|
|
318
|
+
}, ms);
|
|
319
|
+
});
|
|
320
|
+
function cancel() {
|
|
321
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
322
|
+
}
|
|
323
|
+
btn.addEventListener("pointerup", () => {
|
|
324
|
+
if (timer) {
|
|
325
|
+
// Released before threshold → short click.
|
|
326
|
+
cancel();
|
|
327
|
+
if (!didLong && opts && typeof opts.onShort === "function") opts.onShort();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
btn.addEventListener("pointerleave", cancel);
|
|
331
|
+
btn.addEventListener("pointercancel", cancel);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---- mountMenuBar ----------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function isMac() {
|
|
337
|
+
return typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || "");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function formatShortcut(s) {
|
|
341
|
+
if (!s) return "";
|
|
342
|
+
const mac = isMac();
|
|
343
|
+
return s
|
|
344
|
+
.replace(/Mod/g, mac ? "⌘" : "Ctrl")
|
|
345
|
+
.replace(/Shift/g, mac ? "⇧" : "Shift")
|
|
346
|
+
.replace(/Alt/g, mac ? "⌥" : "Alt")
|
|
347
|
+
.replace(/\+/g, mac ? "" : "+");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function mountMenuBar(barEl, MENUS) {
|
|
351
|
+
let openDropdown = null;
|
|
352
|
+
let openBtn = null; // currently-open top-level button (gets .open class)
|
|
353
|
+
|
|
354
|
+
function closeDropdown() {
|
|
355
|
+
if (openDropdown && openDropdown.parentNode) openDropdown.parentNode.removeChild(openDropdown);
|
|
356
|
+
openDropdown = null;
|
|
357
|
+
if (openBtn) openBtn.classList.remove("open");
|
|
358
|
+
openBtn = null;
|
|
359
|
+
document.removeEventListener("mousedown", outsideHandler, true);
|
|
360
|
+
document.removeEventListener("keydown", keyHandler, true);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function outsideHandler(ev) {
|
|
364
|
+
if (openDropdown && !openDropdown.contains(ev.target) && !barEl.contains(ev.target)) closeDropdown();
|
|
365
|
+
}
|
|
366
|
+
function keyHandler(ev) {
|
|
367
|
+
if (!openDropdown) return;
|
|
368
|
+
if (ev.key === "Escape") { ev.stopPropagation(); closeDropdown(); }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function openMenu(menu, anchorBtn) {
|
|
372
|
+
closeDropdown();
|
|
373
|
+
const dd = el("div", { class: "menu-dropdown", dataset: { menuId: menu.id } });
|
|
374
|
+
for (const item of menu.items) {
|
|
375
|
+
if (item.type === "separator") {
|
|
376
|
+
dd.appendChild(el("div", { class: "menu-dropdown-separator" }));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const disabled = typeof item.disabled === "function" ? !!item.disabled() : !!item.disabled;
|
|
380
|
+
const row = el("button", { class: "menu-dropdown-item" + (disabled ? " disabled" : "") });
|
|
381
|
+
// SM-205: a fixed-width check gutter on every item so all labels align
|
|
382
|
+
// in one column; the active/checked item shows a ✓ in the gutter.
|
|
383
|
+
const checked = typeof item.checked === "function" ? !!item.checked() : !!item.checked;
|
|
384
|
+
row.appendChild(el("span", { class: "menu-check", text: checked ? "✓" : "" }));
|
|
385
|
+
row.appendChild(el("span", { class: "menu-label", text: item.label }));
|
|
386
|
+
if (item.shortcut) row.appendChild(el("span", { class: "menu-shortcut", text: formatShortcut(item.shortcut) }));
|
|
387
|
+
if (!disabled) {
|
|
388
|
+
row.addEventListener("click", () => {
|
|
389
|
+
const fn = item.action;
|
|
390
|
+
closeDropdown();
|
|
391
|
+
if (typeof fn === "function") {
|
|
392
|
+
try { fn(); } catch (e) { console.error(e); }
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
dd.appendChild(row);
|
|
397
|
+
}
|
|
398
|
+
document.body.appendChild(dd);
|
|
399
|
+
const rect = anchorBtn.getBoundingClientRect();
|
|
400
|
+
dd.style.top = (rect.bottom) + "px";
|
|
401
|
+
dd.style.left = rect.left + "px";
|
|
402
|
+
openDropdown = dd;
|
|
403
|
+
openBtn = anchorBtn;
|
|
404
|
+
anchorBtn.classList.add("open");
|
|
405
|
+
document.addEventListener("mousedown", outsideHandler, true);
|
|
406
|
+
document.addEventListener("keydown", keyHandler, true);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function render() {
|
|
410
|
+
barEl.innerHTML = "";
|
|
411
|
+
barEl.classList.add("menu-bar");
|
|
412
|
+
for (const m of MENUS) {
|
|
413
|
+
const btn = el("button", { class: "menu-item", text: m.label, dataset: { menuId: m.id } });
|
|
414
|
+
btn.addEventListener("click", (ev) => {
|
|
415
|
+
ev.stopPropagation();
|
|
416
|
+
if (openDropdown && openDropdown.dataset.menuId === m.id) { closeDropdown(); return; }
|
|
417
|
+
openMenu(m, btn);
|
|
418
|
+
});
|
|
419
|
+
barEl.appendChild(btn);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
render();
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
destroy() { closeDropdown(); barEl.innerHTML = ""; }
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---- Exports ---------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
CONSTANTS,
|
|
433
|
+
showModal,
|
|
434
|
+
showSettingsPopover,
|
|
435
|
+
showContextMenu,
|
|
436
|
+
flashStatus,
|
|
437
|
+
wireDebounced,
|
|
438
|
+
bindLongPress,
|
|
439
|
+
mountMenuBar
|
|
440
|
+
};
|
|
441
|
+
}));
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SM-246/248/256 — Process-Step editor view.
|
|
2
|
+
//
|
|
3
|
+
// A SECOND view onto the same map: the process steps that the story-map draws
|
|
4
|
+
// as backbone *headers* are drawn here as *cards* — using the SAME shared card
|
|
5
|
+
// component as the item cards (rendererCard.renderCard), only with a different
|
|
6
|
+
// cluster colour (teal), and reordered with the EXACT same dnd mechanic as the
|
|
7
|
+
// Map's item cards (drag → live insertion projection → drop). Architecture
|
|
8
|
+
// principle: DRY — no second card component, no parallel drag wiring. Reorder
|
|
9
|
+
// projects straight back into the story-map backbone (store.reorderProcessSteps).
|
|
10
|
+
//
|
|
11
|
+
// Interactions mirror the item cards: double-click opens the existing
|
|
12
|
+
// process-step edit dialog (rename/status/delete) — no inline edit, no per-card
|
|
13
|
+
// delete. The cards deliberately show NO tickets (the journey, zoomed out). A
|
|
14
|
+
// quick-add input at the end lets a whole journey be typed in one go.
|
|
15
|
+
//
|
|
16
|
+
// UMD-wrapped: same source runs in the browser AND require()s in Node tests.
|
|
17
|
+
(function (root, factory) {
|
|
18
|
+
if (typeof module === "object" && module.exports) {
|
|
19
|
+
module.exports = factory(require("./core.js"), require("./dnd.js"), require("./renderer-card.js"));
|
|
20
|
+
} else {
|
|
21
|
+
(root.STORYMAP = root.STORYMAP || {}).viewProcessSteps = factory(
|
|
22
|
+
root.STORYMAP && root.STORYMAP.core,
|
|
23
|
+
root.STORYMAP && root.STORYMAP.dnd,
|
|
24
|
+
root.STORYMAP && root.STORYMAP.rendererCard
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}(typeof self !== "undefined" ? self : this, function (core, dnd, rendererCard) {
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
if (!core) throw new Error("view-process-steps: core module missing");
|
|
31
|
+
if (!rendererCard) throw new Error("view-process-steps: renderer-card module missing");
|
|
32
|
+
|
|
33
|
+
const PSV_LAYOUT = {
|
|
34
|
+
CARD_WIDTH_PX: 220, // same fixed card width as the story-map items
|
|
35
|
+
// Process-step cards wear the SAME colour as the story-map backbone spine
|
|
36
|
+
// (green + light text) so the two surfaces read as the same steps. The
|
|
37
|
+
// `process` cluster carries no own colour — .psv-flow paints it from the
|
|
38
|
+
// shared --backbone-* tokens (see storymap.css).
|
|
39
|
+
CLUSTER: "process",
|
|
40
|
+
DRAG_TYPE: "psv-step"
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const LOCAL_ACTOR = { type: "human", id: "local", name: "Local" };
|
|
44
|
+
|
|
45
|
+
function el(tag, attrs, text) {
|
|
46
|
+
const d = document.createElement(tag);
|
|
47
|
+
if (attrs) for (const k in attrs) {
|
|
48
|
+
if (k === "class") d.className = attrs[k];
|
|
49
|
+
else if (k === "dataset") for (const dk in attrs[k]) d.dataset[dk] = attrs[k][dk];
|
|
50
|
+
else d.setAttribute(k, attrs[k]);
|
|
51
|
+
}
|
|
52
|
+
if (text != null) d.textContent = text;
|
|
53
|
+
return d;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pure view model: the live process steps in journey order with ordinals.
|
|
58
|
+
* No tickets / no epic hull — this is the zoomed-out journey surface. (The
|
|
59
|
+
* epic picker for the split lives in the split dialog, P4, via
|
|
60
|
+
* core.tickets.epicsInProcessStep — not on the card.)
|
|
61
|
+
*/
|
|
62
|
+
function buildProcessStepModel(snapshot) {
|
|
63
|
+
const steps = (snapshot.processSteps || [])
|
|
64
|
+
.filter(p => !p.isDeleted)
|
|
65
|
+
.slice()
|
|
66
|
+
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
67
|
+
return steps.map((ps, idx) => ({ step: ps, ordinal: idx + 1 }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pure reorder helper — mirrors the Map's item-reorder model: remove the
|
|
72
|
+
* dragged id, re-insert it at `insertionIndex`, return the new id order.
|
|
73
|
+
* store.reorderProcessSteps then renumbers sortOrder = index.
|
|
74
|
+
*/
|
|
75
|
+
function reorderProcessStepIds(steps, draggedId, insertionIndex) {
|
|
76
|
+
const ids = steps.map(s => s.id);
|
|
77
|
+
const from = ids.indexOf(draggedId);
|
|
78
|
+
if (from < 0) return ids;
|
|
79
|
+
ids.splice(from, 1);
|
|
80
|
+
const idx = Math.max(0, Math.min(ids.length, insertionIndex));
|
|
81
|
+
ids.splice(idx, 0, draggedId);
|
|
82
|
+
return ids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mount(host, store, ctx) {
|
|
86
|
+
ctx = ctx || {};
|
|
87
|
+
|
|
88
|
+
// Live drag projection — same shape/role as the story-map renderer's
|
|
89
|
+
// _dragProjection: { stepId, insertionIndex }. Drives the shadow preview.
|
|
90
|
+
let proj = null;
|
|
91
|
+
|
|
92
|
+
function setProj(next) {
|
|
93
|
+
const same = (proj === next) ||
|
|
94
|
+
(proj && next && proj.stepId === next.stepId && proj.insertionIndex === next.insertionIndex);
|
|
95
|
+
if (same) return;
|
|
96
|
+
proj = next;
|
|
97
|
+
render();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Apply the projection: remove the dragged card from its slot and insert
|
|
101
|
+
// it as a shadow at the insertion index (same trick as effectiveStoriesFor).
|
|
102
|
+
function effectiveOrder(model) {
|
|
103
|
+
if (!proj) return model.map(m => ({ step: m.step, shadow: false }));
|
|
104
|
+
const dragged = model.find(m => m.step.id === proj.stepId);
|
|
105
|
+
const without = model.filter(m => m.step.id !== proj.stepId)
|
|
106
|
+
.map(m => ({ step: m.step, shadow: false }));
|
|
107
|
+
if (!dragged) return without;
|
|
108
|
+
const idx = Math.max(0, Math.min(without.length, proj.insertionIndex || 0));
|
|
109
|
+
without.splice(idx, 0, { step: dragged.step, shadow: true });
|
|
110
|
+
return without;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderStepCard(entry) {
|
|
114
|
+
return rendererCard.renderCard({
|
|
115
|
+
cluster: PSV_LAYOUT.CLUSTER,
|
|
116
|
+
dataset: { processStepId: entry.step.id },
|
|
117
|
+
width: PSV_LAYOUT.CARD_WIDTH_PX,
|
|
118
|
+
isShadow: !!entry.shadow,
|
|
119
|
+
title: entry.step.name || "(unnamed step)",
|
|
120
|
+
meta: null, // no tickets / no status — the step itself
|
|
121
|
+
onDblClick: function () { if (typeof ctx.onEditProcessStep === "function") ctx.onEditProcessStep(entry.step.id); },
|
|
122
|
+
dragType: PSV_LAYOUT.DRAG_TYPE,
|
|
123
|
+
dragId: entry.step.id,
|
|
124
|
+
dragOnEnd: function () { if (proj) setProj(null); }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Quick-add: a permanent inline input at the end of the flow. Enter creates
|
|
130
|
+
* the step AND keeps the field focused for the next one — a whole journey
|
|
131
|
+
* can be typed in one go (the use-case-analysis tempo). Esc clears.
|
|
132
|
+
*/
|
|
133
|
+
function renderQuickAdd() {
|
|
134
|
+
const box = el("div", { class: "psv-quickadd" });
|
|
135
|
+
const input = el("input", {
|
|
136
|
+
class: "psv-quickadd-input", type: "text",
|
|
137
|
+
placeholder: "+ Add step (Enter)", "aria-label": "Add a process step"
|
|
138
|
+
});
|
|
139
|
+
input.addEventListener("keydown", (ev) => {
|
|
140
|
+
if (ev.key === "Escape") { input.value = ""; return; }
|
|
141
|
+
if (ev.key !== "Enter") return;
|
|
142
|
+
const name = input.value.trim();
|
|
143
|
+
if (!name) return;
|
|
144
|
+
// Set the refocus flag BEFORE the commit: createProcessStep fires the
|
|
145
|
+
// store subscriber synchronously, so render() runs inside this call and
|
|
146
|
+
// must already see the flag to refocus the fresh quick-add input.
|
|
147
|
+
ctx._refocusQuickAdd = true;
|
|
148
|
+
try {
|
|
149
|
+
store.createProcessStep({ name }, LOCAL_ACTOR);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
ctx._refocusQuickAdd = false;
|
|
152
|
+
if (ctx.flashStatus) ctx.flashStatus(err.message || "add failed", { kind: "error" });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
box.appendChild(input);
|
|
156
|
+
return box;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function wireFlowDropTarget(flow, model) {
|
|
160
|
+
if (!dnd || typeof dnd.enableDropTarget !== "function") return;
|
|
161
|
+
dnd.enableDropTarget(flow, {
|
|
162
|
+
accepts: [PSV_LAYOUT.DRAG_TYPE],
|
|
163
|
+
onMove: ({ id, clientX }) => {
|
|
164
|
+
// Insertion index from cursor-X vs the REAL (non-shadow) card mids —
|
|
165
|
+
// identical to the Map's epic-reorder onMove (horizontal flow).
|
|
166
|
+
const cards = Array.from(flow.querySelectorAll(".sm-story-card[data-process-step-id]"))
|
|
167
|
+
.filter(c => !c.classList.contains("sm-card-shadow"));
|
|
168
|
+
let insertionIndex = cards.length;
|
|
169
|
+
for (let i = 0; i < cards.length; i++) {
|
|
170
|
+
const r = cards[i].getBoundingClientRect();
|
|
171
|
+
if (clientX < r.left + r.width / 2) { insertionIndex = i; break; }
|
|
172
|
+
}
|
|
173
|
+
setProj({ stepId: id, insertionIndex: insertionIndex });
|
|
174
|
+
},
|
|
175
|
+
onDrop: ({ id }) => {
|
|
176
|
+
const insertionIndex = (proj && proj.stepId === id) ? proj.insertionIndex : null;
|
|
177
|
+
if (insertionIndex == null) return; // no projection → no move
|
|
178
|
+
const ordered = reorderProcessStepIds(model.map(m => m.step), id, insertionIndex);
|
|
179
|
+
try { store.reorderProcessSteps(ordered, LOCAL_ACTOR); }
|
|
180
|
+
catch (err) { if (ctx.flashStatus) ctx.flashStatus(err.message || "reorder failed", { kind: "error" }); }
|
|
181
|
+
// proj is cleared by the draggable's onEnd (dragOnEnd → setProj(null)).
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function render() {
|
|
187
|
+
// Focus guard: don't stomp the quick-add input the user is typing into
|
|
188
|
+
// when an EXTERNAL commit (MCP/WS) triggers a re-render. Our own create
|
|
189
|
+
// sets _refocusQuickAdd, which bypasses the guard so the list refreshes.
|
|
190
|
+
const active = host.ownerDocument && host.ownerDocument.activeElement;
|
|
191
|
+
if (!ctx._refocusQuickAdd && active && host.contains(active)
|
|
192
|
+
&& active.classList && active.classList.contains("psv-quickadd-input")) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const model = buildProcessStepModel(store.get());
|
|
197
|
+
host.innerHTML = "";
|
|
198
|
+
const rootEl = el("div", { class: "psv-root" });
|
|
199
|
+
|
|
200
|
+
const header = el("div", { class: "psv-header" });
|
|
201
|
+
header.appendChild(el("h2", { class: "psv-heading" }, "Process Steps"));
|
|
202
|
+
header.appendChild(el("p", { class: "psv-sub" },
|
|
203
|
+
"The customer journey, left to right — the same process steps as the story-map backbone, zoomed out. "
|
|
204
|
+
+ "Drag to reorder; double-click a step to edit it."));
|
|
205
|
+
rootEl.appendChild(header);
|
|
206
|
+
|
|
207
|
+
const flow = el("div", { class: "psv-flow" });
|
|
208
|
+
if (model.length === 0) {
|
|
209
|
+
flow.appendChild(el("div", { class: "psv-empty" },
|
|
210
|
+
"No process steps yet — type the first journey step below."));
|
|
211
|
+
} else {
|
|
212
|
+
const display = effectiveOrder(model);
|
|
213
|
+
display.forEach((entry) => flow.appendChild(renderStepCard(entry)));
|
|
214
|
+
}
|
|
215
|
+
flow.appendChild(renderQuickAdd());
|
|
216
|
+
wireFlowDropTarget(flow, model);
|
|
217
|
+
rootEl.appendChild(flow);
|
|
218
|
+
host.appendChild(rootEl);
|
|
219
|
+
|
|
220
|
+
if (ctx._refocusQuickAdd) {
|
|
221
|
+
ctx._refocusQuickAdd = false;
|
|
222
|
+
const qa = host.querySelector(".psv-quickadd-input");
|
|
223
|
+
if (qa && typeof qa.focus === "function") qa.focus();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
render();
|
|
228
|
+
const unsub = store.subscribe ? store.subscribe(() => render()) : null;
|
|
229
|
+
return { unmount() { if (typeof unsub === "function") unsub(); host.innerHTML = ""; } };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { mount, buildProcessStepModel, reorderProcessStepIds, PSV_LAYOUT };
|
|
233
|
+
}));
|