atagen

malice

public
Star0
Watch0
0bc1aca195fb7ffce50e1865090cbdfcbf999f63
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/back-stack.tsTypescript129 lines3.6 KB
1
const KEY = 'backLayer';
2
const SETTLE_MS = 400;
3
4
export interface HistoryLike {
5
  state: unknown;
6
  pushState(state: unknown, unused: string, url?: string): void;
7
  replaceState(state: unknown, unused: string, url?: string): void;
8
  back(): void;
9
}
10
11
interface Layer {
12
  id: string;
13
  close: (id: string) => void;
14
}
15
16
function layerOf(state: unknown): string | undefined {
17
  if (state && typeof state === 'object' && KEY in state) return String((state as Record<string, unknown>)[KEY]);
18
  return undefined;
19
}
20
21
function plain(state: unknown): Record<string, unknown> {
22
  const { [KEY]: _drop, ...rest } = (state && typeof state === 'object' ? state : {}) as Record<string, unknown>;
23
  return rest;
24
}
25
26
export class BackStack {
27
  private stack: Layer[] = [];
28
  private queue: Array<() => void> = [];
29
  private waiting = false;
30
  private timer: ReturnType<typeof setTimeout> | null = null;
31
32
  constructor(private readonly history: HistoryLike) {}
33
34
  /** A reload can land on a layer entry whose layer no longer exists. */
35
  init(): void {
36
    if (layerOf(this.history.state) !== undefined) this.history.replaceState(plain(this.history.state), '');
37
  }
38
39
  get depth(): number {
40
    return this.stack.length;
41
  }
42
43
  sync(id: string, open: boolean, close: (id: string) => void): void {
44
    const index = this.stack.findIndex((l) => l.id === id);
45
    if (open && index < 0) {
46
      this.stack.push({ id, close });
47
      this.run(() => this.history.pushState({ ...plain(this.history.state), [KEY]: id }, ''));
48
    } else if (open) {
49
      this.stack[index].close = close;
50
    } else if (index >= 0) {
51
      const top = index === this.stack.length - 1;
52
      this.stack.splice(index, 1);
53
      if (top) this.run(() => this.rewind());
54
    }
55
  }
56
57
  /** Records a route, replacing the layer entry on top when layers are being dismissed by the navigation. */
58
  route(state: unknown, url: string): void {
59
    this.run(() => {
60
      if (this.stack.length > 0 && layerOf(this.history.state) !== undefined) {
61
        this.stack = [];
62
        this.history.replaceState(state, '', url);
63
      } else {
64
        this.history.pushState(state, '', url);
65
      }
66
    });
67
  }
68
69
  /** Returns true when the popstate belonged to the layer stack, false when it is a plain route change. */
70
  pop(state: unknown): boolean {
71
    if (this.waiting) {
72
      this.settle();
73
      return true;
74
    }
75
    const id = layerOf(state);
76
    if (id === undefined) {
77
      if (this.stack.length === 0) return false;
78
      this.closeAbove(-1);
79
      return true;
80
    }
81
    const index = this.stack.findIndex((l) => l.id === id);
82
    if (index < 0) {
83
      this.history.back();
84
      return true;
85
    }
86
    this.closeAbove(index);
87
    return true;
88
  }
89
90
  private closeAbove(index: number): void {
91
    const closing = this.stack.splice(index + 1);
92
    for (const layer of closing.reverse()) layer.close(layer.id);
93
  }
94
95
  private rewind(): void {
96
    if (layerOf(this.history.state) === undefined) return;
97
    this.waiting = true;
98
    this.timer = setTimeout(() => this.settle(), SETTLE_MS);
99
    this.history.back();
100
  }
101
102
  private settle(): void {
103
    this.waiting = false;
104
    if (this.timer !== null) clearTimeout(this.timer);
105
    this.timer = null;
106
    const next = this.queue.shift();
107
    if (next) this.run(next);
108
  }
109
110
  private run(op: () => void): void {
111
    if (this.waiting) {
112
      this.queue.push(op);
113
      return;
114
    }
115
    op();
116
    if (!this.waiting) {
117
      const next = this.queue.shift();
118
      if (next) this.run(next);
119
    }
120
  }
121
}
122
123
export const backStack = new BackStack(typeof window === 'undefined' ? ({} as HistoryLike) : window.history);
124
125
let instances = 0;
126
127
export function instanceLayerId(prefix: string): string {
128
  return `${prefix}-${++instances}`;
129
}