Something went wrong. Try again.
Rewild Your Web
Something went wrong. Try again.
8.0 kB · 285 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286// SPDX-License-Identifier: AGPL-3.0-or-later
/** * HomeModel : the homescreen's arrangement. * * Placement is flow order, not coordinates. Each section owns an ordered list * and the grid packs it in order, so a move is an index change. * */
/** Sizes an item may take, as [columns, rows] against a 4-column grid. */export const SIZES = [ [1, 1], [2, 1], [2, 2], [4, 1], [4, 2],];
/** * A live item is one big enough to be worth rendering as a page. 1x1 is an * icon; everything else is the page itself. This is the single rule the whole * presentation model rests on, so it lives in one function. */export function isLive(item) { return !(item.w === 1 && item.h === 1);}
/** * The widget name in a `builtin:` URL, or null when this is an ordinary URL. */export function builtinName(item) { return item.url?.startsWith("builtin:") ? item.url.slice(8) : null;}
/** * The URL an item opens, which is not always the URL it shows. */export function openUrl(item) { return item?.open || item?.url;}
/** * Whether this item can be opened as a view. */export function isOpenable(item) { return builtinName({ url: openUrl(item) }) === null;}
/** * Whether a builtin name can be resolved to a module and an element. */export function isBuiltinName(name) { return typeof name === "string" && /^[a-z][a-z0-9-]*$/.test(name);}
function clampSize(w, h) { const match = SIZES.find(([sw, sh]) => sw === w && sh === h); return match ?? SIZES[0];}
let idCounter = 0;
/* Unique within a run, which is all an id needs to be: it keys DOM nodes and addresses items in mutations. The timestamp prefix keeps ids from colliding with a restored snapshot's, since the counter restarts at zero on load. */function makeId(prefix) { idCounter += 1; return `${prefix}${Date.now().toString(36)}${idCounter.toString(36)}`;}
export function makeItem({ url, title = "", w = 1, h = 1, icon = null, open = null,}) { const [cw, ch] = clampSize(w, h); return { id: makeId("i"), url, title, w: cw, h: ch, icon, open };}
export function makeSection({ name = "", collapsed = false, items = [] } = {}) { return { id: makeId("s"), name, collapsed, items };}
/* A snapshot is only usable if the parts we index into are the right shape. Anything malformed is dropped rather than repaired: a homescreen that silently loses one bad item is better than one that throws on render, and the caller falls back to the default layout when nothing survives. */function sanitizeItem(raw) { if (!raw || typeof raw.url !== "string" || !raw.url) { return null; } const [w, h] = clampSize(raw.w, raw.h); return { id: typeof raw.id === "string" && raw.id ? raw.id : makeId("i"), url: raw.url, title: typeof raw.title === "string" ? raw.title : "", w, h, icon: typeof raw.icon === "string" ? raw.icon : null, // Optional: see openUrl(). Absent means "opens what it shows". open: typeof raw.open === "string" ? raw.open : null, };}
function sanitizeSection(raw) { if (!raw || !Array.isArray(raw.items)) { return null; } return { id: typeof raw.id === "string" && raw.id ? raw.id : makeId("s"), name: typeof raw.name === "string" ? raw.name : "", collapsed: raw.collapsed === true, items: raw.items.map(sanitizeItem).filter(Boolean), };}
export class HomeModel { /** * @param {object} [snapshot] Shape as produced by {@link toSnapshot}. Missing * or malformed parts fall back to empty rather than throwing. */ constructor(snapshot = {}) { this.sections = Array.isArray(snapshot.sections) ? snapshot.sections.map(sanitizeSection).filter(Boolean) : []; this.prefs = { ...(snapshot.prefs ?? {}) }; }
/** True when there is nothing to render, so the caller can seed a default. */ get isEmpty() { return this.sections.every((section) => section.items.length === 0); }
toSnapshot() { return { sections: this.sections, prefs: this.prefs }; }
section(sectionId) { return this.sections.find((s) => s.id === sectionId) ?? null; }
/** The ordered list behind a section id, or null if there is no such list. */ list(sectionId) { return this.section(sectionId)?.items ?? null; }
/** Locate an item without the caller having to know which section holds it. */ find(itemId) { for (const section of this.sections) { const at = section.items.findIndex((i) => i.id === itemId); if (at !== -1) { return { item: section.items[at], containerId: section.id, index: at }; } } return null; }
addItem(sectionId, item, index = -1) { const list = this.list(sectionId); if (!list) { return null; } if (index < 0 || index >= list.length) { list.push(item); } else { list.splice(index, 0, item); } return item; }
removeItem(itemId) { const found = this.find(itemId); if (!found) { return null; } this.list(found.containerId).splice(found.index, 1); return found.item; }
/** * Move an item into `containerId` at `index`, across containers or within * one. Returns false when the item or target does not exist. * * `index` is an insertion gap in the list as the caller currently sees it, * not the item's final position: in [a, b, c], gap 2 is "between b and c", * so moving a there gives [b, a, c]. That is what a drop between two tiles * means, and it is why a same-list forward move compensates for its own * removal here rather than in every caller. Gap `list.length` (or -1) is the * end. */ moveItem(itemId, sectionId, index = -1) { const found = this.find(itemId); const target = this.list(sectionId); if (!found || !target) { return false; } this.list(found.containerId).splice(found.index, 1); let at = index; if (found.containerId === sectionId && at > found.index) { at -= 1; } this.addItem(sectionId, found.item, at); return true; }
/** Resize to the next size up, wrapping back to 1x1. */ cycleSize(itemId, direction = 1) { const found = this.find(itemId); if (!found) { return null; } const { item } = found; const current = SIZES.findIndex(([w, h]) => w === item.w && h === item.h); const next = (current + direction + SIZES.length) % SIZES.length; [item.w, item.h] = SIZES[next]; return item; }
setSize(itemId, w, h) { const found = this.find(itemId); if (!found) { return null; } [found.item.w, found.item.h] = clampSize(w, h); return found.item; }
addSection(section, index = -1) { if (index < 0 || index >= this.sections.length) { this.sections.push(section); } else { this.sections.splice(index, 0, section); } return section; }
/** Removing a section takes its items with it; the caller confirms first. */ removeSection(sectionId) { const index = this.sections.findIndex((s) => s.id === sectionId); if (index === -1) { return null; } return this.sections.splice(index, 1)[0]; }
renameSection(sectionId, name) { const section = this.section(sectionId); if (section) { section.name = name; } return section; }
toggleCollapsed(sectionId, collapsed) { const section = this.section(sectionId); if (!section) { return null; } section.collapsed = collapsed ?? !section.collapsed; return section; }
/** Reorder a section. `index` is an insertion gap, as in moveItem. */ moveSection(sectionId, index) { const from = this.sections.findIndex((s) => s.id === sectionId); if (from === -1) { return false; } const [section] = this.sections.splice(from, 1); let at = index; if (at > from) { at -= 1; } if (at < 0 || at >= this.sections.length) { this.sections.push(section); } else { this.sections.splice(at, 0, section); } return true; }}