mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 12:18:39 +00:00
The Widgets page had a Refresh button that was a hard reset: it disposed every mounted widget, forgot every owner Stop, rebuilt every card and remounted everything, asking for confirmation only while a card kept running in the background would be stopped. With the launch policy, the passive card list and the keyed patch in place, its only remaining unique job was picking up an edited module source without leaving the page, and one misclick could stop every long-running program. The owner chose to remove it. The window reload is now the only hard reset. It ends every frame with its window and forgets the page-session Stops, which is what the documentation already promised for a reload. On a surface without a reload affordance, such as the packaged desktop shell or the Telegram mini app, the equivalent boundary is closing Ouroboros, which the documentation already lists beside the reload. Removed: the button and its page-header action slot, the click handler, the `force` flag of `render()` (with it the blanket `stoppedByOwner` clear and the forced dispose), the button's loading state in the list sync, `confirmWidgetsRestart` and the now unused confirm-dialog import in widget_card.js, and the button's CSS rule. Net 82 lines lighter. Tests follow the behaviour rather than the button. Two tests of the removed feature are deleted, because the code they covered is gone: the Node test of the confirmation dialog and the static test of the button's loading state. Everything else is rewritten, not dropped: the browser suite now proves that an owner override applies on the next Widgets entry, that a window reload kills a kept-running frame and starts a fresh one, and that a reload forgets an owner Stop, with a negative pin asserting the page carries no Refresh control; the static suite pins the absence of the button, the handler, the helper and the force flag. Docs updated in ARCHITECTURE, DEVELOPMENT and CREATING_SKILLS: the force-destroy boundaries of a kept card are now Stop, the skill leaving the live list, a window reload and closing Ouroboros. The Tab/focus-order residual now names the window reload in all four places it appears. Verified: Node 881 passed (rc 0; one case fewer than before this commit, the dialog test that went with the dialog), tests/test_widgets_ui_static.py 25 passed (rc 0; one case fewer, the loading-state test), the full suite in an isolated data root (rc 0), size ratchet --check (rc 0), and the Widgets lifecycle browser suite on Chromium and WebKit, 6 passed (rc 0). Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
127 lines
5.6 KiB
JavaScript
127 lines
5.6 KiB
JavaScript
/* Widgets card order: the owner's `widget_order` preference applied to the card
|
|
list, the pure key-order move behind a reorder, and the drag / keyboard
|
|
reorder handles on the cards. A reorder never moves an <article>: the visual
|
|
order is the masonry key order (web/modules/masonry.js), so a running frame —
|
|
retained or not — is never reloaded by it. widgets.js owns persisting the
|
|
order through `/api/ui/preferences` and relayouting with it. Disclosed
|
|
residual: the Tab / focus order follows the DOM, so after a visual reorder it
|
|
can differ from the visible order until a window reload rebuilds the cards;
|
|
keyboard reorder through the handle follows the key order. */
|
|
|
|
import { widgetKey } from './widget_list.js';
|
|
|
|
export function normalizeWidgetOrder(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
const seen = new Set();
|
|
return value
|
|
.map((item) => String(item || '').trim())
|
|
.filter((item) => {
|
|
if (!item || seen.has(item)) return false;
|
|
seen.add(item);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function sortTabsByWidgetOrder(tabs, order) {
|
|
const rank = new Map(normalizeWidgetOrder(order).map((key, idx) => [key, idx]));
|
|
return tabs.map((tab, originalIndex) => ({ tab, originalIndex })).sort((a, b) => {
|
|
const aRank = rank.has(widgetKey(a.tab)) ? rank.get(widgetKey(a.tab)) : Number.MAX_SAFE_INTEGER;
|
|
const bRank = rank.has(widgetKey(b.tab)) ? rank.get(widgetKey(b.tab)) : Number.MAX_SAFE_INTEGER;
|
|
if (aRank !== bRank) return aRank - bRank;
|
|
return a.originalIndex - b.originalIndex;
|
|
}).map((item) => item.tab);
|
|
}
|
|
|
|
/**
|
|
* Pure key-order move: `key` leaves its slot and re-enters at `toIndex`
|
|
* (clamped to the list). A drop onto another card passes that card's index,
|
|
* which lands the dragged key after a target it was before and before a target
|
|
* it was after. Returns the SAME array when nothing changes, so callers test
|
|
* identity for "moved".
|
|
*/
|
|
export function moveWidgetKey(order, key, toIndex) {
|
|
const from = order.indexOf(key);
|
|
if (from < 0 || !order.length) return order;
|
|
const target = Math.max(0, Math.min(order.length - 1, Math.trunc(Number(toIndex) || 0)));
|
|
if (target === from) return order;
|
|
const next = order.slice();
|
|
next.splice(from, 1);
|
|
next.splice(target, 0, key);
|
|
return next;
|
|
}
|
|
|
|
// Cards keep their DOM node across list patches, so binding is per card, once;
|
|
// the drag source is shared by every binding pass over the one Widgets list.
|
|
const reorderBoundCards = new WeakSet();
|
|
let draggedKey = '';
|
|
|
|
/**
|
|
* Drag and keyboard reorder on the card handles. `currentOrder()` returns the
|
|
* complete visible key order; a move hands the next order to `onOrderChange`
|
|
* and touches no node.
|
|
*/
|
|
export function bindWidgetCardReorder(list, currentOrder, onOrderChange) {
|
|
if (!list) return;
|
|
const clearDragState = () => {
|
|
list.querySelectorAll('.widgets-card.dragging, .widgets-card.drag-over').forEach((card) => {
|
|
card.classList.remove('dragging', 'drag-over');
|
|
});
|
|
draggedKey = '';
|
|
};
|
|
const move = (key, toIndex) => {
|
|
const order = currentOrder();
|
|
const next = moveWidgetKey(order, key, toIndex);
|
|
if (next === order) return false;
|
|
onOrderChange(next);
|
|
return true;
|
|
};
|
|
list.querySelectorAll('[data-widget-reorder-handle]').forEach((handle) => {
|
|
const card = handle.closest('[data-widget-key]');
|
|
if (!card || reorderBoundCards.has(card)) return;
|
|
handle.setAttribute('draggable', 'true');
|
|
handle.addEventListener('dragstart', (event) => {
|
|
draggedKey = card.dataset.widgetKey || '';
|
|
if (!draggedKey) return;
|
|
card.classList.add('dragging');
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', draggedKey);
|
|
}
|
|
});
|
|
handle.addEventListener('dragend', clearDragState);
|
|
handle.addEventListener('keydown', (event) => {
|
|
const key = card.dataset.widgetKey || '';
|
|
const from = currentOrder().indexOf(key);
|
|
if (from < 0) return;
|
|
let toIndex = from;
|
|
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') toIndex = from - 1;
|
|
else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') toIndex = from + 1;
|
|
else if (event.key === 'Home') toIndex = 0;
|
|
else if (event.key === 'End') toIndex = Number.MAX_SAFE_INTEGER;
|
|
else return;
|
|
if (!move(key, toIndex)) return;
|
|
event.preventDefault();
|
|
clearDragState();
|
|
handle.focus();
|
|
});
|
|
});
|
|
list.querySelectorAll('.widgets-card').forEach((card) => {
|
|
if (reorderBoundCards.has(card)) return;
|
|
reorderBoundCards.add(card);
|
|
card.addEventListener('dragover', (event) => {
|
|
if (!draggedKey || card.dataset.widgetKey === draggedKey) return;
|
|
event.preventDefault();
|
|
card.classList.add('drag-over');
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
});
|
|
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
|
|
card.addEventListener('drop', (event) => {
|
|
if (!draggedKey || card.dataset.widgetKey === draggedKey) return;
|
|
event.preventDefault();
|
|
const key = draggedKey;
|
|
const targetIndex = currentOrder().indexOf(card.dataset.widgetKey || '');
|
|
clearDragState();
|
|
if (targetIndex >= 0) move(key, targetIndex);
|
|
});
|
|
});
|
|
}
|