Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 87 additions & 10 deletions js/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,6 @@ Menu.prototype.addPinEntry = function (id) {
if (!entry) {
// id was deleted after pin (or something) so remove it
delete this._pinnedIds[id];
this.persistPinEntries();
return;
}

Expand All @@ -510,7 +509,6 @@ Menu.prototype.addPinEntry = function (id) {
this.showPins();
}
this._pinnedIds[id] = true;
this.persistPinEntries();
};

Menu.prototype.removePinEntry = function (id) {
Expand All @@ -520,23 +518,84 @@ Menu.prototype.removePinEntry = function (id) {
if (Object.keys(this._pinnedIds).length === 0) {
this.hidePins();
}

this.persistPinEntries();
};

Menu.prototype.unpinAll = function () {
for (let id of Object.keys(this._pinnedIds)) {
this.removePinEntry(id);
}
this.persistPinEntries();
};

Menu.prototype.pinListClick = function (event) {
if (event?.target?.classList.contains('unpin')) {
let id = event.target.parentNode.dataset.sectionId;
if (id) {
this.removePinEntry(id);
this.persistPinEntries();
}
}
};

// All per-document pins live under this one key, as an object of
// { [documentPath]: { pins: string[], lastUsed: <ms epoch> } } (see #702).
const PIN_STORAGE_KEY = 'pinEntries';
// Forget a document's pins if it hasn't been visited in this long, so that the
// unique-per-PR paths used by preview deployments don't grow localStorage without
// bound.
const PIN_TTL_MS = 180 * 24 * 60 * 60 * 1000; // 180 days

// Identify the current document by path so pins in one spec/preview never clobber
// another served from the same origin. Returns the path only; the localStorage key
// is the constant above.
Menu.prototype.pinDocumentPath = function () {
// Directory of the current document (drop any filename such as index.html / foo.html).
let dir = location.pathname.replace(/[^/]*$/, '');
// Multipage pages live at <root>/multipage/<page>.html; fold them onto the
// document root so every page of one spec shares a single set of pins.
if (usesMultipage && dir.endsWith('/multipage/')) {
dir = dir.slice(0, -'multipage/'.length);
}
return dir;
};

// Read the store as { path: { pins, lastUsed } }, migrating the legacy global
// `pinEntries` array (attributing it to the current document) if present.
Menu.prototype.readPinStore = function () {
let raw = window.localStorage[PIN_STORAGE_KEY];
if (!raw) return {};
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
return {};
}
if (Array.isArray(parsed)) {
// Legacy format: one global array of ids shared across all documents. Ids not
// in this document are dropped later by addPinEntry().
return { [this.pinDocumentPath()]: { pins: parsed, lastUsed: Date.now() } };
}
return parsed && typeof parsed === 'object' ? parsed : {};
};

// Drop documents not visited within PIN_TTL_MS. Mutates and returns `store`.
Menu.prototype.prunePinStore = function (store) {
let now = Date.now();
for (let path of Object.keys(store)) {
let entry = store[path];
if (!entry || typeof entry.lastUsed !== 'number' || now - entry.lastUsed > PIN_TTL_MS) {
delete store[path];
}
}
return store;
};

Menu.prototype.writePinStore = function (store) {
try {
window.localStorage[PIN_STORAGE_KEY] = JSON.stringify(store);
} catch (e) {
// localStorage may be full or unavailable; pins are best-effort, so ignore.
}
};

Menu.prototype.persistPinEntries = function () {
Expand All @@ -546,7 +605,16 @@ Menu.prototype.persistPinEntries = function () {
return;
}

localStorage.pinEntries = JSON.stringify(Object.keys(this._pinnedIds));
let store = this.prunePinStore(this.readPinStore());
let path = this.pinDocumentPath();
let ids = Object.keys(this._pinnedIds);
if (ids.length === 0) {
// Don't leave an empty entry lingering once the last pin is removed.
delete store[path];
} else {
store[path] = { pins: ids, lastUsed: Date.now() };
}
this.writePinStore(store);
};

Menu.prototype.loadPinEntries = function () {
Expand All @@ -556,12 +624,20 @@ Menu.prototype.loadPinEntries = function () {
return;
}

let pinsString = window.localStorage.pinEntries;
if (!pinsString) return;
let pins = JSON.parse(pinsString);
for (let i = 0; i < pins.length; i++) {
this.addPinEntry(pins[i]);
// Prune on every load (this also completes legacy-array migration and TTL
// cleanup), then persist so the cleanup sticks even for documents with no pins.
let store = this.prunePinStore(this.readPinStore());
let entry = store[this.pinDocumentPath()];
this.writePinStore(store);
if (!entry) return;

// addPinEntry only updates in-memory state + the DOM (and drops ids missing from
// this document); commit once afterwards to bump lastUsed and store the cleaned
// set of ids.
for (let i = 0; i < entry.pins.length; i++) {
this.addPinEntry(entry.pins[i]);
}
this.persistPinEntries();
};

Menu.prototype.togglePinEntry = function (id) {
Expand All @@ -574,6 +650,7 @@ Menu.prototype.togglePinEntry = function (id) {
} else {
this.addPinEntry(id);
}
this.persistPinEntries();
};

Menu.prototype.selectPin = function (num) {
Expand Down
97 changes: 87 additions & 10 deletions test/baselines/generated-reference/assets-inline.html
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,6 @@
if (!entry) {
// id was deleted after pin (or something) so remove it
delete this._pinnedIds[id];
this.persistPinEntries();
return;
}

Expand All @@ -594,7 +593,6 @@
this.showPins();
}
this._pinnedIds[id] = true;
this.persistPinEntries();
};

Menu.prototype.removePinEntry = function (id) {
Expand All @@ -604,23 +602,84 @@
if (Object.keys(this._pinnedIds).length === 0) {
this.hidePins();
}

this.persistPinEntries();
};

Menu.prototype.unpinAll = function () {
for (let id of Object.keys(this._pinnedIds)) {
this.removePinEntry(id);
}
this.persistPinEntries();
};

Menu.prototype.pinListClick = function (event) {
if (event?.target?.classList.contains('unpin')) {
let id = event.target.parentNode.dataset.sectionId;
if (id) {
this.removePinEntry(id);
this.persistPinEntries();
}
}
};

// All per-document pins live under this one key, as an object of
// { [documentPath]: { pins: string[], lastUsed: <ms epoch> } } (see #702).
const PIN_STORAGE_KEY = 'pinEntries';
// Forget a document's pins if it hasn't been visited in this long, so that the
// unique-per-PR paths used by preview deployments don't grow localStorage without
// bound.
const PIN_TTL_MS = 180 * 24 * 60 * 60 * 1000; // 180 days

// Identify the current document by path so pins in one spec/preview never clobber
// another served from the same origin. Returns the path only; the localStorage key
// is the constant above.
Menu.prototype.pinDocumentPath = function () {
// Directory of the current document (drop any filename such as index.html / foo.html).
let dir = location.pathname.replace(/[^/]*$/, '');
// Multipage pages live at <root>/multipage/<page>.html; fold them onto the
// document root so every page of one spec shares a single set of pins.
if (usesMultipage && dir.endsWith('/multipage/')) {
dir = dir.slice(0, -'multipage/'.length);
}
return dir;
};

// Read the store as { path: { pins, lastUsed } }, migrating the legacy global
// `pinEntries` array (attributing it to the current document) if present.
Menu.prototype.readPinStore = function () {
let raw = window.localStorage[PIN_STORAGE_KEY];
if (!raw) return {};
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
return {};
}
if (Array.isArray(parsed)) {
// Legacy format: one global array of ids shared across all documents. Ids not
// in this document are dropped later by addPinEntry().
return { [this.pinDocumentPath()]: { pins: parsed, lastUsed: Date.now() } };
}
return parsed && typeof parsed === 'object' ? parsed : {};
};

// Drop documents not visited within PIN_TTL_MS. Mutates and returns `store`.
Menu.prototype.prunePinStore = function (store) {
let now = Date.now();
for (let path of Object.keys(store)) {
let entry = store[path];
if (!entry || typeof entry.lastUsed !== 'number' || now - entry.lastUsed > PIN_TTL_MS) {
delete store[path];
}
}
return store;
};

Menu.prototype.writePinStore = function (store) {
try {
window.localStorage[PIN_STORAGE_KEY] = JSON.stringify(store);
} catch (e) {
// localStorage may be full or unavailable; pins are best-effort, so ignore.
}
};

Menu.prototype.persistPinEntries = function () {
Expand All @@ -630,7 +689,16 @@
return;
}

localStorage.pinEntries = JSON.stringify(Object.keys(this._pinnedIds));
let store = this.prunePinStore(this.readPinStore());
let path = this.pinDocumentPath();
let ids = Object.keys(this._pinnedIds);
if (ids.length === 0) {
// Don't leave an empty entry lingering once the last pin is removed.
delete store[path];
} else {
store[path] = { pins: ids, lastUsed: Date.now() };
}
this.writePinStore(store);
};

Menu.prototype.loadPinEntries = function () {
Expand All @@ -640,12 +708,20 @@
return;
}

let pinsString = window.localStorage.pinEntries;
if (!pinsString) return;
let pins = JSON.parse(pinsString);
for (let i = 0; i < pins.length; i++) {
this.addPinEntry(pins[i]);
// Prune on every load (this also completes legacy-array migration and TTL
// cleanup), then persist so the cleanup sticks even for documents with no pins.
let store = this.prunePinStore(this.readPinStore());
let entry = store[this.pinDocumentPath()];
this.writePinStore(store);
if (!entry) return;

// addPinEntry only updates in-memory state + the DOM (and drops ids missing from
// this document); commit once afterwards to bump lastUsed and store the cleaned
// set of ids.
for (let i = 0; i < entry.pins.length; i++) {
this.addPinEntry(entry.pins[i]);
}
this.persistPinEntries();
};

Menu.prototype.togglePinEntry = function (id) {
Expand All @@ -658,6 +734,7 @@
} else {
this.addPinEntry(id);
}
this.persistPinEntries();
};

Menu.prototype.selectPin = function (num) {
Expand Down