@kgdiff/viewer 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/controller.d.ts +22 -74
  8. package/dist/controller.d.ts.map +1 -1
  9. package/dist/controller.js +35 -376
  10. package/dist/controller.js.map +1 -1
  11. package/dist/dom.d.ts +10 -0
  12. package/dist/dom.d.ts.map +1 -1
  13. package/dist/dom.js +29 -0
  14. package/dist/dom.js.map +1 -1
  15. package/dist/element.d.ts +52 -46
  16. package/dist/element.d.ts.map +1 -1
  17. package/dist/element.js +362 -403
  18. package/dist/element.js.map +1 -1
  19. package/dist/elements/actions.d.ts +27 -0
  20. package/dist/elements/actions.d.ts.map +1 -0
  21. package/dist/elements/actions.js +81 -0
  22. package/dist/elements/actions.js.map +1 -0
  23. package/dist/elements/bound.d.ts +63 -0
  24. package/dist/elements/bound.d.ts.map +1 -0
  25. package/dist/elements/bound.js +213 -0
  26. package/dist/elements/bound.js.map +1 -0
  27. package/dist/elements/change-detail.d.ts +25 -0
  28. package/dist/elements/change-detail.d.ts.map +1 -0
  29. package/dist/elements/change-detail.js +19 -0
  30. package/dist/elements/change-detail.js.map +1 -0
  31. package/dist/elements/change-list.d.ts +27 -0
  32. package/dist/elements/change-list.d.ts.map +1 -0
  33. package/dist/elements/change-list.js +29 -0
  34. package/dist/elements/change-list.js.map +1 -0
  35. package/dist/elements/filters.d.ts +25 -0
  36. package/dist/elements/filters.d.ts.map +1 -0
  37. package/dist/elements/filters.js +19 -0
  38. package/dist/elements/filters.js.map +1 -0
  39. package/dist/elements/legend.d.ts +40 -0
  40. package/dist/elements/legend.d.ts.map +1 -0
  41. package/dist/elements/legend.js +87 -0
  42. package/dist/elements/legend.js.map +1 -0
  43. package/dist/elements/minimap.d.ts +38 -0
  44. package/dist/elements/minimap.d.ts.map +1 -0
  45. package/dist/elements/minimap.js +59 -0
  46. package/dist/elements/minimap.js.map +1 -0
  47. package/dist/elements/root.d.ts +40 -0
  48. package/dist/elements/root.d.ts.map +1 -0
  49. package/dist/elements/root.js +67 -0
  50. package/dist/elements/root.js.map +1 -0
  51. package/dist/elements/search.d.ts +29 -0
  52. package/dist/elements/search.d.ts.map +1 -0
  53. package/dist/elements/search.js +23 -0
  54. package/dist/elements/search.js.map +1 -0
  55. package/dist/elements/stage-context.d.ts +31 -0
  56. package/dist/elements/stage-context.d.ts.map +1 -0
  57. package/dist/elements/stage-context.js +4 -0
  58. package/dist/elements/stage-context.js.map +1 -0
  59. package/dist/elements/stage.d.ts +65 -0
  60. package/dist/elements/stage.d.ts.map +1 -0
  61. package/dist/elements/stage.js +259 -0
  62. package/dist/elements/stage.js.map +1 -0
  63. package/dist/elements/title.d.ts +25 -0
  64. package/dist/elements/title.d.ts.map +1 -0
  65. package/dist/elements/title.js +19 -0
  66. package/dist/elements/title.js.map +1 -0
  67. package/dist/elements/transport.d.ts +29 -0
  68. package/dist/elements/transport.d.ts.map +1 -0
  69. package/dist/elements/transport.js +24 -0
  70. package/dist/elements/transport.js.map +1 -0
  71. package/dist/elements/view-bound.d.ts +39 -0
  72. package/dist/elements/view-bound.d.ts.map +1 -0
  73. package/dist/elements/view-bound.js +88 -0
  74. package/dist/elements/view-bound.js.map +1 -0
  75. package/dist/elements/view-dock.d.ts +28 -0
  76. package/dist/elements/view-dock.d.ts.map +1 -0
  77. package/dist/elements/view-dock.js +28 -0
  78. package/dist/elements/view-dock.js.map +1 -0
  79. package/dist/elements/zoom.d.ts +28 -0
  80. package/dist/elements/zoom.d.ts.map +1 -0
  81. package/dist/elements/zoom.js +22 -0
  82. package/dist/elements/zoom.js.map +1 -0
  83. package/dist/i18n.d.ts +10 -0
  84. package/dist/i18n.d.ts.map +1 -1
  85. package/dist/i18n.js +20 -0
  86. package/dist/i18n.js.map +1 -1
  87. package/dist/icons.d.ts +1 -0
  88. package/dist/icons.d.ts.map +1 -1
  89. package/dist/icons.js +1 -0
  90. package/dist/icons.js.map +1 -1
  91. package/dist/index.d.ts +328 -34
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +13 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/keyboard.d.ts +39 -2
  96. package/dist/keyboard.d.ts.map +1 -1
  97. package/dist/keyboard.js +145 -62
  98. package/dist/keyboard.js.map +1 -1
  99. package/dist/layout-store.d.ts +26 -0
  100. package/dist/layout-store.d.ts.map +1 -0
  101. package/dist/layout-store.js +46 -0
  102. package/dist/layout-store.js.map +1 -0
  103. package/dist/panel-levels.d.ts +18 -0
  104. package/dist/panel-levels.d.ts.map +1 -0
  105. package/dist/panel-levels.js +41 -0
  106. package/dist/panel-levels.js.map +1 -0
  107. package/dist/panels.d.ts +28 -0
  108. package/dist/panels.d.ts.map +1 -0
  109. package/dist/panels.js +73 -0
  110. package/dist/panels.js.map +1 -0
  111. package/dist/prefs.d.ts +5 -2
  112. package/dist/prefs.d.ts.map +1 -1
  113. package/dist/prefs.js +12 -3
  114. package/dist/prefs.js.map +1 -1
  115. package/dist/react.d.ts +460 -0
  116. package/dist/react.d.ts.map +1 -0
  117. package/dist/react.js +2 -0
  118. package/dist/react.js.map +1 -0
  119. package/dist/root-element.d.ts +122 -0
  120. package/dist/root-element.d.ts.map +1 -0
  121. package/dist/root-element.js +541 -0
  122. package/dist/root-element.js.map +1 -0
  123. package/dist/root.d.ts +30 -30
  124. package/dist/root.d.ts.map +1 -1
  125. package/dist/root.js +65 -42
  126. package/dist/root.js.map +1 -1
  127. package/dist/session.d.ts +19 -3
  128. package/dist/session.d.ts.map +1 -1
  129. package/dist/session.js +15 -17
  130. package/dist/session.js.map +1 -1
  131. package/dist/standalone/kgdiff-viewer.js +4 -4
  132. package/dist/styles.d.ts +4 -0
  133. package/dist/styles.d.ts.map +1 -1
  134. package/dist/styles.js +224 -84
  135. package/dist/styles.js.map +1 -1
  136. package/dist/theme.d.ts +7 -0
  137. package/dist/theme.d.ts.map +1 -1
  138. package/dist/theme.js +10 -7
  139. package/dist/theme.js.map +1 -1
  140. package/dist/topics.d.ts +8 -0
  141. package/dist/topics.d.ts.map +1 -0
  142. package/dist/topics.js +18 -0
  143. package/dist/topics.js.map +1 -0
  144. package/dist/ui/change-list.d.ts.map +1 -1
  145. package/dist/ui/change-list.js +2 -2
  146. package/dist/ui/change-list.js.map +1 -1
  147. package/dist/ui/context.d.ts +20 -7
  148. package/dist/ui/context.d.ts.map +1 -1
  149. package/dist/ui/context.js.map +1 -1
  150. package/dist/ui/header.d.ts +41 -3
  151. package/dist/ui/header.d.ts.map +1 -1
  152. package/dist/ui/header.js +99 -34
  153. package/dist/ui/header.js.map +1 -1
  154. package/dist/ui/legend.d.ts +15 -0
  155. package/dist/ui/legend.d.ts.map +1 -0
  156. package/dist/ui/legend.js +68 -0
  157. package/dist/ui/legend.js.map +1 -0
  158. package/dist/ui/popovers.d.ts +3 -1
  159. package/dist/ui/popovers.d.ts.map +1 -1
  160. package/dist/ui/popovers.js +62 -23
  161. package/dist/ui/popovers.js.map +1 -1
  162. package/dist/ui/search.d.ts.map +1 -1
  163. package/dist/ui/search.js +3 -2
  164. package/dist/ui/search.js.map +1 -1
  165. package/dist/ui/sidebar.d.ts +26 -3
  166. package/dist/ui/sidebar.d.ts.map +1 -1
  167. package/dist/ui/sidebar.js +344 -12
  168. package/dist/ui/sidebar.js.map +1 -1
  169. package/dist/ui/stage.d.ts +56 -1
  170. package/dist/ui/stage.d.ts.map +1 -1
  171. package/dist/ui/stage.js +244 -256
  172. package/dist/ui/stage.js.map +1 -1
  173. package/dist/ui/transport.d.ts +12 -0
  174. package/dist/ui/transport.d.ts.map +1 -0
  175. package/dist/ui/transport.js +198 -0
  176. package/dist/ui/transport.js.map +1 -0
  177. package/dist/ui/zoom.d.ts +15 -0
  178. package/dist/ui/zoom.d.ts.map +1 -0
  179. package/dist/ui/zoom.js +30 -0
  180. package/dist/ui/zoom.js.map +1 -0
  181. package/dist/view.d.ts +94 -0
  182. package/dist/view.d.ts.map +1 -0
  183. package/dist/view.js +441 -0
  184. package/dist/view.js.map +1 -0
  185. package/package.json +95 -12
  186. package/src/bind.ts +80 -0
  187. package/src/controller.ts +51 -442
  188. package/src/dom.ts +30 -0
  189. package/src/element.ts +364 -406
  190. package/src/elements/actions.ts +94 -0
  191. package/src/elements/bound.ts +247 -0
  192. package/src/elements/change-detail.ts +34 -0
  193. package/src/elements/change-list.ts +51 -0
  194. package/src/elements/filters.ts +34 -0
  195. package/src/elements/legend.ts +103 -0
  196. package/src/elements/minimap.ts +75 -0
  197. package/src/elements/root.ts +86 -0
  198. package/src/elements/search.ts +38 -0
  199. package/src/elements/stage-context.ts +34 -0
  200. package/src/elements/stage.ts +285 -0
  201. package/src/elements/title.ts +34 -0
  202. package/src/elements/transport.ts +39 -0
  203. package/src/elements/view-bound.ts +118 -0
  204. package/src/elements/view-dock.ts +43 -0
  205. package/src/elements/zoom.ts +38 -0
  206. package/src/i18n.ts +20 -0
  207. package/src/icons.ts +1 -0
  208. package/src/index.ts +347 -34
  209. package/src/keyboard.ts +188 -61
  210. package/src/layout-store.ts +71 -0
  211. package/src/panel-levels.ts +53 -0
  212. package/src/panels.ts +81 -0
  213. package/src/prefs.ts +12 -4
  214. package/src/react.ts +459 -0
  215. package/src/root-element.ts +624 -0
  216. package/src/root.ts +92 -65
  217. package/src/session.ts +22 -33
  218. package/src/styles.ts +245 -84
  219. package/src/theme.ts +10 -7
  220. package/src/topics.ts +37 -0
  221. package/src/ui/change-list.ts +6 -2
  222. package/src/ui/context.ts +20 -7
  223. package/src/ui/header.ts +127 -56
  224. package/src/ui/legend.ts +112 -0
  225. package/src/ui/popovers.ts +68 -25
  226. package/src/ui/search.ts +10 -5
  227. package/src/ui/sidebar.ts +386 -23
  228. package/src/ui/stage.ts +267 -331
  229. package/src/ui/transport.ts +240 -0
  230. package/src/ui/zoom.ts +45 -0
  231. package/src/view.ts +516 -0
  232. package/vscode.html-custom-data.json +726 -0
package/src/keyboard.ts CHANGED
@@ -33,52 +33,108 @@ export interface KeyLike {
33
33
  readonly altKey?: boolean;
34
34
  }
35
35
 
36
- const MODE_KEYS: Readonly<Record<string, ViewerMode>> = {
37
- '1': 'overlay',
38
- '2': 'swipe',
39
- '3': 'replay',
40
- };
41
-
42
36
  /** Divider steps for swipe, as a fraction of the width. */
43
37
  export const DIVIDER_STEP = 0.02;
44
38
  export const DIVIDER_STEP_LARGE = 0.1;
45
39
 
46
40
  /**
47
- * Map a key press to an action in `mode` and `scope`, or null when the key means nothing here.
48
- * Chords with Ctrl / Meta / Alt are never handled (they belong to the browser and the host page).
41
+ * The shortcuts a key can be bound to (`keymap`). `left` / `right` step the replay, or move the
42
+ * swipe divider (further with Shift); `play-pause` acts in replay, `expand` with the `changes`
43
+ * scope.
44
+ *
45
+ * @experimental New in 0.6: the list may grow as the canvas chrome becomes elements.
49
46
  */
50
- export function keyAction(
47
+ export type ShortcutName =
48
+ | 'overlay'
49
+ | 'swipe'
50
+ | 'replay'
51
+ | 'scope'
52
+ | 'expand'
53
+ | 'next'
54
+ | 'prev'
55
+ | 'first'
56
+ | 'last'
57
+ | 'fly'
58
+ | 'escape'
59
+ | 'play-pause'
60
+ | 'left'
61
+ | 'right'
62
+ | 'fit'
63
+ | 'zoom-in'
64
+ | 'zoom-out'
65
+ | 'search'
66
+ | 'review'
67
+ | 'sidebar'
68
+ | 'theme'
69
+ | 'help';
70
+
71
+ /**
72
+ * Keys (as `KeyboardEvent.key`) bound to a shortcut, or to `null` to leave the key unbound. A
73
+ * letter stands for both its cases (`x` also binds Shift+x, `X`) unless the other case is named
74
+ * too.
75
+ *
76
+ * @experimental New in 0.6, with `ShortcutName`.
77
+ */
78
+ export type Keymap = Readonly<Record<string, ShortcutName | null>>;
79
+
80
+ /** The default keyboard map (docs/interaction.md §Keyboard map); each letter once. */
81
+ export const DEFAULT_KEYMAP: Keymap = {
82
+ '1': 'overlay',
83
+ '2': 'swipe',
84
+ '3': 'replay',
85
+ '4': 'scope',
86
+ j: 'next',
87
+ ArrowDown: 'next',
88
+ k: 'prev',
89
+ ArrowUp: 'prev',
90
+ Home: 'first',
91
+ End: 'last',
92
+ Enter: 'fly',
93
+ Escape: 'escape',
94
+ ' ': 'play-pause',
95
+ Spacebar: 'play-pause',
96
+ ArrowLeft: 'left',
97
+ ArrowRight: 'right',
98
+ f: 'fit',
99
+ '+': 'zoom-in',
100
+ '=': 'zoom-in',
101
+ '-': 'zoom-out',
102
+ _: 'zoom-out',
103
+ '−': 'zoom-out',
104
+ '/': 'search',
105
+ x: 'review',
106
+ '\\': 'sidebar',
107
+ t: 'theme',
108
+ e: 'expand',
109
+ '?': 'help',
110
+ };
111
+
112
+ /** Every shortcut (each is bound to a key by default). */
113
+ export const SHORTCUT_NAMES: ReadonlySet<string> = new Set(
114
+ Object.values(DEFAULT_KEYMAP).filter((name) => name !== null),
115
+ );
116
+
117
+ /** What `name` does in `mode` and `scope`, or null when it does nothing there. */
118
+ function resolve(
119
+ name: ShortcutName,
51
120
  e: KeyLike,
52
121
  mode: ViewerMode,
53
- scope: ViewerScope = 'all',
122
+ scope: ViewerScope,
54
123
  ): KeyAction | null {
55
- if (e.ctrlKey || e.metaKey || e.altKey) return null;
56
- const key = e.key;
57
- const mapped = MODE_KEYS[key];
58
- if (mapped !== undefined) return { type: 'mode', mode: mapped };
59
- switch (key) {
60
- case '4':
124
+ switch (name) {
125
+ case 'overlay':
126
+ case 'swipe':
127
+ case 'replay':
128
+ return { type: 'mode', mode: name };
129
+ case 'scope':
61
130
  return { type: 'scope' };
62
- case 'j':
63
- case 'ArrowDown':
64
- return { type: 'next' };
65
- case 'k':
66
- case 'ArrowUp':
67
- return { type: 'prev' };
68
- case 'Home':
69
- return { type: 'first' };
70
- case 'End':
71
- return { type: 'last' };
72
- case 'Enter':
73
- return { type: 'fly' };
74
- case 'Escape':
75
- return { type: 'escape' };
76
- case ' ':
77
- case 'Spacebar':
131
+ case 'expand':
132
+ return scope === 'changes' ? { type: 'expand' } : null;
133
+ case 'play-pause':
78
134
  return mode === 'replay' ? { type: 'play-pause' } : null;
79
- case 'ArrowLeft':
80
- case 'ArrowRight': {
81
- const direction = key === 'ArrowRight' ? 1 : -1;
135
+ case 'left':
136
+ case 'right': {
137
+ const direction = name === 'right' ? 1 : -1;
82
138
  if (mode === 'replay') return { type: 'step', direction };
83
139
  if (mode === 'swipe') {
84
140
  return {
@@ -88,34 +144,105 @@ export function keyAction(
88
144
  }
89
145
  return null;
90
146
  }
91
- case 'f':
92
- case 'F':
93
- return { type: 'fit' };
94
- case '+':
95
- case '=':
96
- return { type: 'zoom', direction: 1 };
97
- case '-':
98
- case '_':
99
- case '−':
100
- return { type: 'zoom', direction: -1 };
101
- case '/':
102
- return { type: 'search' };
103
- case 'x':
104
- case 'X':
105
- return { type: 'review' };
106
- case '\\':
107
- return { type: 'sidebar' };
108
- case 't':
109
- case 'T':
110
- return { type: 'theme' };
111
- case 'e':
112
- case 'E':
113
- return scope === 'changes' ? { type: 'expand' } : null;
114
- case '?':
115
- return { type: 'help' };
147
+ case 'zoom-in':
148
+ case 'zoom-out':
149
+ return { type: 'zoom', direction: name === 'zoom-in' ? 1 : -1 };
116
150
  default:
117
- return null;
151
+ return { type: name };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * What `map` binds `key` to: the key itself, else the other case of a letter; undefined when
157
+ * `map` names neither.
158
+ */
159
+ function lookup(map: Keymap, key: string): ShortcutName | null | undefined {
160
+ if (Object.hasOwn(map, key)) return map[key];
161
+ const lower = key.toLowerCase();
162
+ const other = key === lower ? key.toUpperCase() : lower;
163
+ return key.length === 1 && other !== key && Object.hasOwn(map, other) ? map[other] : undefined;
164
+ }
165
+
166
+ /**
167
+ * Map a key press to an action in `mode` and `scope`, or null when the key means nothing here.
168
+ * `keymap` overrides single keys of the default map (null unbinds one); a letter in either map
169
+ * stands for both its cases unless the other case is named too. Chords with Ctrl / Meta / Alt
170
+ * are never handled (they belong to the browser and the host page).
171
+ */
172
+ export function keyAction(
173
+ e: KeyLike,
174
+ mode: ViewerMode,
175
+ scope: ViewerScope = 'all',
176
+ keymap: Keymap | null = null,
177
+ ): KeyAction | null {
178
+ if (e.ctrlKey || e.metaKey || e.altKey) return null;
179
+ const custom = keymap === null ? undefined : lookup(keymap, e.key);
180
+ const name = custom === undefined ? lookup(DEFAULT_KEYMAP, e.key) : custom;
181
+ return name ? resolve(name, e, mode, scope) : null;
182
+ }
183
+
184
+ /**
185
+ * Keys the default map binds for another key's sake: listed only when they do something else
186
+ * (the `+` key unshifted, `-` with Shift, the minus sign, the legacy name of the space bar).
187
+ */
188
+ const ALIASES: Readonly<Record<string, string>> = { '=': '+', _: '-', '−': '-', Spacebar: ' ' };
189
+
190
+ /**
191
+ * The keys bound to each shortcut with `keymap` over the default map, in the default map's
192
+ * order then `keymap`'s (what the shortcuts dialog lists). A letter is listed once for both its
193
+ * cases; a shortcut no key is bound to is absent.
194
+ */
195
+ export function shortcutKeys(keymap: Keymap | null): ReadonlyMap<ShortcutName, readonly string[]> {
196
+ const candidates = new Set([...Object.keys(DEFAULT_KEYMAP), ...Object.keys(keymap ?? {})]);
197
+ const bound = (key: string): ShortcutName | null | undefined => {
198
+ const custom = keymap === null ? undefined : lookup(keymap, key);
199
+ return custom === undefined ? lookup(DEFAULT_KEYMAP, key) : custom;
200
+ };
201
+ const out = new Map<ShortcutName, string[]>();
202
+ for (const key of candidates) {
203
+ const name = bound(key);
204
+ if (!name) continue;
205
+ const lower = key.toLowerCase();
206
+ if (key.length === 1 && key !== lower && candidates.has(lower) && bound(lower) === name) {
207
+ continue;
208
+ }
209
+ const alias = ALIASES[key];
210
+ if (alias !== undefined && bound(alias) === name) continue;
211
+ const keys = out.get(name) ?? [];
212
+ keys.push(key);
213
+ out.set(name, keys);
118
214
  }
215
+ return out;
216
+ }
217
+
218
+ /** `Space` names the space bar in a `keymap` attribute. */
219
+ const KEY_NAMES: Readonly<Record<string, string>> = { Space: ' ' };
220
+
221
+ /**
222
+ * A keymap from an attribute (`"x:none r:review"`: `key:shortcut` pairs separated by spaces,
223
+ * `none` unbinds a key, `Space` is the space bar) or a property (an object of the same pairs).
224
+ * Pairs that name no shortcut are left out and returned in `invalid`.
225
+ */
226
+ export function toKeymap(value: unknown): { keymap: Keymap | null; invalid: string[] } {
227
+ const invalid: string[] = [];
228
+ const out: Record<string, ShortcutName | null> = {};
229
+ const add = (key: string, name: unknown, raw: string): void => {
230
+ const k = KEY_NAMES[key] ?? key;
231
+ if (k === '' || !(name === null || name === 'none' || SHORTCUT_NAMES.has(name as string))) {
232
+ invalid.push(raw);
233
+ } else out[k] = name === null || name === 'none' ? null : (name as ShortcutName);
234
+ };
235
+ if (typeof value === 'string') {
236
+ for (const token of value.split(/\s+/)) {
237
+ if (token === '') continue;
238
+ const at = token.lastIndexOf(':');
239
+ if (at <= 0) invalid.push(token);
240
+ else add(token.slice(0, at), token.slice(at + 1), token);
241
+ }
242
+ } else if (typeof value === 'object' && value !== null) {
243
+ for (const [key, name] of Object.entries(value)) add(key, name, `${key}:${String(name)}`);
244
+ } else return { keymap: null, invalid };
245
+ return { keymap: Object.keys(out).length > 0 ? out : null, invalid };
119
246
  }
120
247
 
121
248
  /** Whether a key event comes from a place where the user types (shortcuts must not fire). */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What the default layout remembers of the user's choices (RFC 0001 §4, decision 3): panel
3
+ * states, the sidebar's width, the split between the change list and the detail panel, theme,
4
+ * palette and language. One `localStorage` entry (`kgdiff:viewer`, or the viewer's
5
+ * `persist-key`), shared by the viewers of the origin that use the same key. Like review progress (`review-store.ts`), storage failures
6
+ * (private mode, quota, sandboxed iframes) never break the viewer: nothing is remembered then.
7
+ * Values are validated when read; an entry from another version is read as far as it is valid.
8
+ */
9
+ import type { Locale } from '@kgdiff/core';
10
+ import type { PaletteName } from '@kgdiff/scene';
11
+ import type { ThemePreference, ViewerPanels } from './index.js';
12
+ import { toPanels } from './panels.js';
13
+ import { toLocale, toPalette, toTheme } from './prefs.js';
14
+ import type { StorageLike } from './review-store.js';
15
+
16
+ /** The default storage key (`persist-key`). */
17
+ export const LAYOUT_KEY = 'kgdiff:viewer';
18
+
19
+ export interface Remembered {
20
+ readonly panels: ViewerPanels;
21
+ readonly sidebarWidth?: number;
22
+ /** Null: the user reset the split (the detail panel takes its content's height). */
23
+ readonly detailShare?: number | null;
24
+ readonly theme?: ThemePreference;
25
+ readonly palette?: PaletteName;
26
+ readonly locale?: Locale;
27
+ }
28
+
29
+ const NOTHING: Remembered = { panels: {} };
30
+
31
+ const finite = (x: unknown, min: number, max: number): number | undefined =>
32
+ typeof x === 'number' && Number.isFinite(x) && x >= min && x <= max ? x : undefined;
33
+
34
+ export function readRemembered(storage: StorageLike | null, key = LAYOUT_KEY): Remembered {
35
+ if (!storage) return NOTHING;
36
+ try {
37
+ const raw = storage.getItem(key);
38
+ if (!raw) return NOTHING;
39
+ const v: unknown = JSON.parse(raw);
40
+ if (typeof v !== 'object' || v === null) return NOTHING;
41
+ const o = v as Record<string, unknown>;
42
+ const sidebarWidth = finite(o.sidebarWidth, 0, 10_000);
43
+ const detailShare = finite(o.detailShare, 0, 1);
44
+ const theme = toTheme(o.theme);
45
+ const palette = toPalette(o.palette);
46
+ const locale = toLocale(o.locale);
47
+ return {
48
+ panels: toPanels(o.panels).panels,
49
+ ...(sidebarWidth !== undefined ? { sidebarWidth } : {}),
50
+ ...(detailShare !== undefined ? { detailShare } : {}),
51
+ ...(theme ? { theme } : {}),
52
+ ...(palette ? { palette } : {}),
53
+ ...(locale ? { locale } : {}),
54
+ };
55
+ } catch {
56
+ return NOTHING;
57
+ }
58
+ }
59
+
60
+ export function writeRemembered(
61
+ storage: StorageLike | null,
62
+ value: Remembered,
63
+ key = LAYOUT_KEY,
64
+ ): void {
65
+ if (!storage) return;
66
+ try {
67
+ storage.setItem(key, JSON.stringify({ v: 1, ...value }));
68
+ } catch {
69
+ // Quota exceeded or storage blocked: the choice holds for this page only.
70
+ }
71
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Which level decides a panel's state in the default layout: the first of these that names the
3
+ * region. What the user changed in this page, the host (`panels`, then the presets `sidebar`,
4
+ * `header` and `chrome`), what the user changed before (remembered, `layout-store.ts`), the
5
+ * report's `view.panels`, the default.
6
+ */
7
+ import type { PanelRegion, PanelStates, ViewerPanels } from './index.js';
8
+ import { DEFAULT_PANELS, PANEL_REGIONS } from './panels.js';
9
+
10
+ type MutablePanels = { -readonly [R in PanelRegion]?: PanelStates[R] };
11
+
12
+ /** The regions of the sidebar (the bottom sheet below 760 px). */
13
+ export const SIDEBAR_REGIONS: readonly PanelRegion[] = ['list', 'detail', 'search', 'filters'];
14
+
15
+ /**
16
+ * What the existing switches stand for: `sidebar="closed"` (and `open`), `header="compact"`,
17
+ * `chrome="none"` (everything but the stage hidden; the legend stays, as one line).
18
+ */
19
+ export function presetPanels(
20
+ sidebar: string | null,
21
+ header: string | null,
22
+ chrome: string | null,
23
+ ): ViewerPanels {
24
+ const out: MutablePanels = {};
25
+ if (chrome === 'none') {
26
+ for (const r of PANEL_REGIONS) if (r !== 'legend') out[r] = 'hidden';
27
+ return out;
28
+ }
29
+ if (sidebar === 'closed' || sidebar === 'open') {
30
+ for (const r of SIDEBAR_REGIONS) out[r] = sidebar === 'open' ? 'open' : 'collapsed';
31
+ }
32
+ if (header === 'compact') out.title = 'hidden';
33
+ return out;
34
+ }
35
+
36
+ /** Every region's state: the first of `levels` that names it, else the default. */
37
+ export function resolvePanels(levels: readonly ViewerPanels[]): PanelStates {
38
+ const out = { ...DEFAULT_PANELS };
39
+ for (const r of PANEL_REGIONS) {
40
+ for (const level of levels) {
41
+ const state = level[r];
42
+ if (state !== undefined) {
43
+ out[r] = state;
44
+ break;
45
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ export function samePanels(a: PanelStates, b: PanelStates): boolean {
52
+ return PANEL_REGIONS.every((r) => a[r] === b[r]);
53
+ }
package/src/panels.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `@kgdiff/viewer/panels`: the syntax of the default layout's panel states (RFC 0001 §4), every
3
+ * region `open`, `collapsed` or `hidden`. The `panels` attribute
4
+ * (`"detail:collapsed minimap:hidden"`), its object property, a report's `view.panels` and the
5
+ * CLI's `--panels` all go through `parsePanels` / `toPanels`. No DOM: the CLI imports it.
6
+ * Which level decides a region's state is `panel-levels.ts`.
7
+ */
8
+ import type { PanelRegion, PanelState, PanelStates, ViewerPanels } from './index.js';
9
+
10
+ export const PANEL_REGIONS: readonly PanelRegion[] = [
11
+ 'list',
12
+ 'detail',
13
+ 'search',
14
+ 'filters',
15
+ 'dock',
16
+ 'transport',
17
+ 'legend',
18
+ 'zoom',
19
+ 'minimap',
20
+ 'title',
21
+ 'actions',
22
+ ];
23
+
24
+ export const PANEL_STATES: readonly PanelState[] = ['open', 'collapsed', 'hidden'];
25
+
26
+ /** Today's layout. The legend card starts folded to its head. */
27
+ export const DEFAULT_PANELS: PanelStates = {
28
+ list: 'open',
29
+ detail: 'open',
30
+ search: 'open',
31
+ filters: 'open',
32
+ dock: 'open',
33
+ transport: 'open',
34
+ legend: 'collapsed',
35
+ zoom: 'open',
36
+ minimap: 'open',
37
+ title: 'open',
38
+ actions: 'open',
39
+ };
40
+
41
+ type MutablePanels = { -readonly [R in PanelRegion]?: PanelState };
42
+
43
+ const isRegion = (r: unknown): r is PanelRegion => PANEL_REGIONS.includes(r as PanelRegion);
44
+ const isState = (s: unknown): s is PanelState => PANEL_STATES.includes(s as PanelState);
45
+
46
+ /**
47
+ * Read `"region:state …"` (pairs separated by spaces or commas). Pairs that name no region or
48
+ * no state are left out and returned in `invalid`; a region named twice takes the last state.
49
+ */
50
+ export function parsePanels(text: string): { panels: ViewerPanels; invalid: string[] } {
51
+ const panels: MutablePanels = {};
52
+ const invalid: string[] = [];
53
+ for (const token of text.split(/[\s,]+/)) {
54
+ if (token === '') continue;
55
+ const [region, state, ...rest] = token.split(':');
56
+ if (rest.length === 0 && isRegion(region) && isState(state)) panels[region] = state;
57
+ else invalid.push(token);
58
+ }
59
+ return { panels, invalid };
60
+ }
61
+
62
+ /** Panels from an attribute, a property or a report (a string or an object), as `parsePanels`. */
63
+ export function toPanels(value: unknown): { panels: ViewerPanels; invalid: string[] } {
64
+ if (typeof value === 'string') return parsePanels(value);
65
+ const panels: MutablePanels = {};
66
+ const invalid: string[] = [];
67
+ if (typeof value === 'object' && value !== null) {
68
+ for (const [region, state] of Object.entries(value)) {
69
+ if (isRegion(region) && isState(state)) panels[region] = state;
70
+ else invalid.push(`${region}:${String(state)}`);
71
+ }
72
+ } else if (value !== null && value !== undefined) invalid.push(String(value));
73
+ return { panels, invalid };
74
+ }
75
+
76
+ /** The attribute form of `panels`, in region order. */
77
+ export function formatPanels(panels: ViewerPanels): string {
78
+ return PANEL_REGIONS.filter((r) => panels[r] !== undefined)
79
+ .map((r) => `${r}:${panels[r]}`)
80
+ .join(' ');
81
+ }
package/src/prefs.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  ViewerMode,
15
15
  ViewerScope,
16
16
  } from './index.js';
17
+ import { toPanels } from './panels.js';
17
18
 
18
19
  const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay'];
19
20
  const SCOPES: readonly ViewerScope[] = ['all', 'changes'];
@@ -69,8 +70,11 @@ export function toDelay(value: unknown): number | null {
69
70
 
70
71
  type View = NonNullable<ReportPayload['view']>;
71
72
 
72
- /** Keep only the valid fields of a report's `view` (warning about the rest). */
73
- export function sanitizeView(view: unknown): View | undefined {
73
+ /**
74
+ * Keep only the valid fields of a report's `view`, warning about the rest (once per payload:
75
+ * `quiet` for a second read of the same view).
76
+ */
77
+ export function sanitizeView(view: unknown, quiet = false): View | undefined {
74
78
  if (typeof view !== 'object' || view === null) return undefined;
75
79
  const v = view as Record<string, unknown>;
76
80
  const legacy = v.mode === LEGACY_CHANGES_MODE;
@@ -79,10 +83,13 @@ export function sanitizeView(view: unknown): View | undefined {
79
83
  const theme = toTheme(v.theme);
80
84
  const palette = toPalette(v.palette);
81
85
  const locale = toLocale(v.locale);
82
- const dropped = (['mode', 'scope', 'theme', 'palette', 'locale'] as const).filter(
86
+ const read = v.panels === undefined ? null : toPanels(v.panels);
87
+ const panels = read && Object.keys(read.panels).length > 0 ? read.panels : null;
88
+ const dropped: string[] = (['mode', 'scope', 'theme', 'palette', 'locale'] as const).filter(
83
89
  (k) => v[k] !== undefined && { mode, scope, theme, palette, locale }[k] === null,
84
90
  );
85
- if (dropped.length > 0 && typeof console !== 'undefined') {
91
+ if (read && read.invalid.length > 0) dropped.push(`panels (${read.invalid.join(' ')})`);
92
+ if (dropped.length > 0 && !quiet && typeof console !== 'undefined') {
86
93
  console.warn(`[kgdiff] ignoring invalid report view preferences: ${dropped.join(', ')}`);
87
94
  }
88
95
  return {
@@ -91,5 +98,6 @@ export function sanitizeView(view: unknown): View | undefined {
91
98
  ...(theme ? { theme } : {}),
92
99
  ...(palette ? { palette } : {}),
93
100
  ...(locale ? { locale } : {}),
101
+ ...(panels ? { panels } : {}),
94
102
  };
95
103
  }