193 lines
7.6 KiB
JavaScript
193 lines
7.6 KiB
JavaScript
(function (root, factory) {
|
|
const api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.createTaskOverlayHistory = api;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
'use strict';
|
|
|
|
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
|
const searchKinds = new Set(['search', 'search-preview']);
|
|
const maxQueryLength = 200;
|
|
|
|
function cleanQuery(value) {
|
|
const query = typeof value === 'string' ? value.trim() : '';
|
|
return query.length <= maxQueryLength ? query : '';
|
|
}
|
|
|
|
function cleanScope(value) {
|
|
const repository = typeof value?.repository === 'string' &&
|
|
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
|
? value.repository.trim() : '';
|
|
return {
|
|
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
|
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
|
repository,
|
|
};
|
|
}
|
|
|
|
function cleanPreview(value) {
|
|
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
|
const repository = typeof value.repository === 'string' ? value.repository : '';
|
|
const parts = repository.split('/');
|
|
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
|
|
const number = Number(value.number);
|
|
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
|
|
return { kind:value.kind, repository, number };
|
|
}
|
|
|
|
function previewToken(preview) {
|
|
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
|
|
}
|
|
|
|
function parsePreview(value) {
|
|
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
|
|
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
|
|
}
|
|
|
|
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
|
|
let started = false;
|
|
let restoredFromUrl = false;
|
|
|
|
function stateDetail(state = history.state) {
|
|
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
|
if (!searchKinds.has(kind)) return { kind };
|
|
const query = cleanQuery(state.searchQuery);
|
|
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
|
|
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
|
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
|
return { kind:'search', ...(query ? { query } : {}) };
|
|
}
|
|
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
|
|
}
|
|
|
|
function urlDetail() {
|
|
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
|
|
const params = new URLSearchParams(location.search || '');
|
|
const rawQuery = params.get('search');
|
|
if (rawQuery === null) return { kind:null };
|
|
const query = cleanQuery(rawQuery);
|
|
if (rawQuery.trim() && !query) return { kind:null };
|
|
const preview = parsePreview(params.get('preview'));
|
|
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
|
|
const scope = hasScope ? cleanScope({
|
|
kind:params.get('search_kind'), state:params.get('search_state'),
|
|
repository:params.get('search_repository'),
|
|
}) : null;
|
|
return {
|
|
kind:preview ? 'search-preview' : 'search',
|
|
...(query ? { query } : {}),
|
|
...(scope ? { scope } : {}),
|
|
...(preview ? { preview } : {}),
|
|
};
|
|
}
|
|
|
|
function toState(detail, base = history.state) {
|
|
const state = { ...(base || {}), taskOverlay:detail.kind };
|
|
delete state.searchQuery;
|
|
delete state.searchPreview;
|
|
delete state.searchScope;
|
|
if (searchKinds.has(detail.kind)) {
|
|
if (detail.query) state.searchQuery = detail.query;
|
|
if (detail.scope) state.searchScope = cleanScope(detail.scope);
|
|
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function urlFor(detail) {
|
|
if (!location || typeof URLSearchParams === 'undefined') return undefined;
|
|
const params = new URLSearchParams(location.search || '');
|
|
params.delete('search');
|
|
params.delete('preview');
|
|
params.delete('search_kind');
|
|
params.delete('search_state');
|
|
params.delete('search_repository');
|
|
if (searchKinds.has(detail.kind)) {
|
|
params.set('search', detail.query || '');
|
|
if (detail.scope) {
|
|
params.set('search_kind', detail.scope.kind);
|
|
params.set('search_state', detail.scope.state);
|
|
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
|
|
}
|
|
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
|
}
|
|
const query = params.toString();
|
|
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
|
|
}
|
|
|
|
let initial = stateDetail();
|
|
if (!initial.kind) {
|
|
const fromUrl = urlDetail();
|
|
if (fromUrl.kind) {
|
|
initial = fromUrl;
|
|
restoredFromUrl = true;
|
|
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
|
|
}
|
|
}
|
|
let active = initial;
|
|
|
|
function apply(state) {
|
|
const next = stateDetail(state);
|
|
if (JSON.stringify(next) === JSON.stringify(active)) return;
|
|
const previous = active.kind;
|
|
active = next;
|
|
onChange(next.kind, previous, next);
|
|
}
|
|
|
|
return {
|
|
start() {
|
|
if (started) return;
|
|
started = true;
|
|
eventTarget.addEventListener('popstate', event => apply(event.state));
|
|
if (restoredFromUrl) onChange(active.kind, null, active);
|
|
},
|
|
open(kind, detail = {}) {
|
|
if (!allowed.has(kind)) return false;
|
|
const next = stateDetail(toState({
|
|
kind, query:cleanQuery(detail.query),
|
|
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
|
|
preview:cleanPreview(detail.preview),
|
|
}));
|
|
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
|
|
const previous = active.kind;
|
|
history.pushState(toState(next), '', urlFor(next));
|
|
active = next;
|
|
onChange(next.kind, previous, next);
|
|
return true;
|
|
},
|
|
update(detail = {}) {
|
|
if (!searchKinds.has(active.kind)) return false;
|
|
const next = stateDetail(toState({
|
|
kind:active.kind,
|
|
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
|
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
|
|
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
|
}));
|
|
history.replaceState(toState(next), '', urlFor(next));
|
|
active = next;
|
|
return true;
|
|
},
|
|
close() {
|
|
if (!active.kind) return false;
|
|
history.back();
|
|
return true;
|
|
},
|
|
leave() {
|
|
if (!active.kind) return false;
|
|
const previous = active.kind;
|
|
const state = { ...(history.state || {}) };
|
|
delete state.taskOverlay;
|
|
delete state.searchQuery;
|
|
delete state.searchPreview;
|
|
delete state.searchScope;
|
|
active = { kind:null };
|
|
history.replaceState(state, '', urlFor(active));
|
|
onChange(null, previous, active);
|
|
return true;
|
|
},
|
|
current() { return active.kind; },
|
|
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
|
|
};
|
|
};
|
|
});
|