@dorsk/tsumikit 0.72.0 → 0.73.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.
- package/README.md +21 -0
- package/dist/components/atoms/Icon.svelte +5 -0
- package/dist/components/atoms/Icon.svelte.d.ts +5 -0
- package/dist/components/atoms/TimeDisplay.svelte +98 -0
- package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
- package/dist/components/layouts/AppShell.svelte +3 -2
- package/dist/components/layouts/ResizablePanel.svelte +5 -4
- package/dist/components/molecules/Accordion.svelte +2 -1
- package/dist/components/molecules/AttachmentList.svelte +1 -0
- package/dist/components/molecules/Breadcrumb.svelte +7 -6
- package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
- package/dist/components/molecules/CapBar.svelte +3 -1
- package/dist/components/molecules/Composer.svelte +27 -5
- package/dist/components/molecules/Drawer.svelte +10 -1
- package/dist/components/molecules/Dropzone.svelte +2 -1
- package/dist/components/molecules/FilterInput.svelte +10 -1
- package/dist/components/molecules/Metric.svelte +28 -3
- package/dist/components/molecules/Modal.svelte +20 -4
- package/dist/components/molecules/Popover.svelte +7 -3
- package/dist/components/molecules/SectionHeader.svelte +29 -14
- package/dist/components/molecules/SeekSlider.svelte +370 -0
- package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
- package/dist/components/molecules/SegmentedControl.svelte +10 -6
- package/dist/components/molecules/SelectButton.svelte +33 -22
- package/dist/components/molecules/Tabs.svelte +11 -5
- package/dist/components/molecules/Timestamp.svelte +1 -1
- package/dist/components/molecules/Toaster.svelte +70 -13
- package/dist/components/molecules/Tooltip.svelte +19 -10
- package/dist/components/molecules/VolumeSlider.svelte +367 -0
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
- package/dist/components/molecules/combobox-keyboard.js +2 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/media-time.d.ts +6 -0
- package/dist/media-time.js +25 -0
- package/dist/query/edit.js +1 -16
- package/dist/query/parser.js +68 -28
- package/dist/query/query.js +9 -5
- package/dist/query/quotes.d.ts +24 -0
- package/dist/query/quotes.js +78 -0
- package/dist/query/single.js +1 -3
- package/dist/query/suggest.js +25 -32
- package/dist/stores/fontscale.svelte.js +3 -2
- package/dist/stores/storage.d.ts +2 -0
- package/dist/stores/storage.js +16 -0
- package/dist/stores/theme.svelte.js +4 -7
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Seconds as `m:ss`, or `h:mm:ss` once the value (or `ref`) reaches an hour. */
|
|
2
|
+
export function formatMediaTime(seconds, ref = seconds) {
|
|
3
|
+
if (!Number.isFinite(seconds))
|
|
4
|
+
return '--:--';
|
|
5
|
+
const neg = seconds < 0;
|
|
6
|
+
const total = Math.floor(Math.abs(seconds));
|
|
7
|
+
const h = Math.floor(total / 3600);
|
|
8
|
+
const m = Math.floor((total % 3600) / 60);
|
|
9
|
+
const s = total % 60;
|
|
10
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
11
|
+
const hours = h > 0 || (Number.isFinite(ref) && Math.abs(ref) >= 3600);
|
|
12
|
+
const body = hours ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
|
13
|
+
return neg ? `-${body}` : body;
|
|
14
|
+
}
|
|
15
|
+
/** True when a duration cannot be scrubbed: unknown, live, or empty. */
|
|
16
|
+
export function isLiveDuration(duration) {
|
|
17
|
+
return duration == null || !Number.isFinite(duration) || duration <= 0;
|
|
18
|
+
}
|
|
19
|
+
/** Spoken position: "1:02 of 23:45", or just the position when live. */
|
|
20
|
+
export function describeMediaTime(value, duration, format = formatMediaTime) {
|
|
21
|
+
if (isLiveDuration(duration))
|
|
22
|
+
return format(value);
|
|
23
|
+
const d = duration;
|
|
24
|
+
return `${format(value, d)} of ${format(d, d)}`;
|
|
25
|
+
}
|
package/dist/query/edit.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// step opens, so spaces land inside the quotes — the already-supported phrase
|
|
9
9
|
// form — without any grammar change. UI-free and framework-agnostic.
|
|
10
10
|
// ─────────────────────────────────────────────────────────────────────────
|
|
11
|
+
import { openQuoteAt } from './quotes';
|
|
11
12
|
import { findField } from './schema';
|
|
12
13
|
// A field name immediately followed by a string operator (`:` contains / `!:`
|
|
13
14
|
// not-contains), anchored at the caret.
|
|
@@ -32,22 +33,6 @@ export function autoQuoteEdit(schema, value, pos) {
|
|
|
32
33
|
return null;
|
|
33
34
|
return { value: `${value.slice(0, pos)}""${value.slice(pos)}`, pos: pos + 1 };
|
|
34
35
|
}
|
|
35
|
-
// Scan from the start tracking quote state (spaces inside quotes are NOT
|
|
36
|
-
// boundaries), so the caret's quote context is correct even for `"star wars"`.
|
|
37
|
-
function openQuoteAt(value, pos) {
|
|
38
|
-
let quote = null;
|
|
39
|
-
for (let i = 0; i < pos; i++) {
|
|
40
|
-
const c = value[i];
|
|
41
|
-
if (quote) {
|
|
42
|
-
if (c === quote)
|
|
43
|
-
quote = null;
|
|
44
|
-
}
|
|
45
|
-
else if (isQuote(c)) {
|
|
46
|
-
quote = c;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return quote;
|
|
50
|
-
}
|
|
51
36
|
/** True when the caret sits inside an open quote. */
|
|
52
37
|
export function insideQuoteAtCaret(value, pos) {
|
|
53
38
|
return openQuoteAt(value, pos) !== null;
|
package/dist/query/parser.js
CHANGED
|
@@ -21,11 +21,16 @@
|
|
|
21
21
|
// genre in (house, techno) → any of
|
|
22
22
|
// year..2021..2024 → range (field..a..b) also field:a..b
|
|
23
23
|
// free floating words → TextNode (full-text)
|
|
24
|
+
// "daft punk" → one TextNode phrase (quotes stripped)
|
|
25
|
+
//
|
|
26
|
+
// `..` only splits a value on fields that accept the range operator, so
|
|
27
|
+
// `cwd:../foo` stays one string. Inside quotes `\"` and `\\` are escapes.
|
|
24
28
|
//
|
|
25
29
|
// Error recovery: unmatched `)` is ignored, a missing `)` closes at end of
|
|
26
30
|
// input, a dangling AND/OR/NOT with no operand collapses to its operand, and an
|
|
27
31
|
// empty group yields nothing. The parser never throws.
|
|
28
32
|
// ─────────────────────────────────────────────────────────────────────────
|
|
33
|
+
import { readQuoted } from './quotes';
|
|
29
34
|
import { findField, operatorByCode, operatorsFor } from './schema';
|
|
30
35
|
const IDENT = /[A-Za-z_][\w-]*/y;
|
|
31
36
|
// Longest operator codes first so `>=` wins over `>`.
|
|
@@ -41,18 +46,16 @@ function skipWsInline(sc) {
|
|
|
41
46
|
while (sc.i < sc.s.length && (sc.s[sc.i] === ' ' || sc.s[sc.i] === '\t'))
|
|
42
47
|
sc.i++;
|
|
43
48
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Read a value token: a "quoted string", or a bare run up to whitespace. With
|
|
51
|
+
* `range` the bare run also stops at `..` so `a..b` splits into two values.
|
|
52
|
+
*/
|
|
53
|
+
function readValue(sc, range = false) {
|
|
46
54
|
skipWsInline(sc);
|
|
47
55
|
if (sc.s[sc.i] === '"' || sc.s[sc.i] === "'") {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
sc.i++;
|
|
52
|
-
const value = sc.s.slice(start, sc.i);
|
|
53
|
-
if (sc.s[sc.i] === quote)
|
|
54
|
-
sc.i++; // consume closing quote
|
|
55
|
-
return { value, end: sc.i };
|
|
56
|
+
const { value, end } = readQuoted(sc.s, sc.i);
|
|
57
|
+
sc.i = end;
|
|
58
|
+
return { value, end };
|
|
56
59
|
}
|
|
57
60
|
const start = sc.i;
|
|
58
61
|
while (sc.i < sc.s.length &&
|
|
@@ -60,8 +63,7 @@ function readValue(sc) {
|
|
|
60
63
|
sc.s[sc.i] !== ',' &&
|
|
61
64
|
sc.s[sc.i] !== ')' &&
|
|
62
65
|
sc.s[sc.i] !== '(' &&
|
|
63
|
-
|
|
64
|
-
!sc.s.startsWith('..', sc.i))
|
|
66
|
+
!(range && sc.s.startsWith('..', sc.i)))
|
|
65
67
|
sc.i++;
|
|
66
68
|
return { value: sc.s.slice(start, sc.i), end: sc.i };
|
|
67
69
|
}
|
|
@@ -99,8 +101,41 @@ function peekKeyword(sc) {
|
|
|
99
101
|
}
|
|
100
102
|
export function parse(input, schema) {
|
|
101
103
|
const sc = { s: input, i: 0 };
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
+
const pieces = [];
|
|
105
|
+
for (;;) {
|
|
106
|
+
const before = sc.i;
|
|
107
|
+
const node = parseOr(sc, schema);
|
|
108
|
+
if (node)
|
|
109
|
+
pieces.push(node);
|
|
110
|
+
skipWs(sc);
|
|
111
|
+
if (sc.i >= sc.s.length)
|
|
112
|
+
break;
|
|
113
|
+
if (sc.s[sc.i] === ')')
|
|
114
|
+
sc.i++;
|
|
115
|
+
else if (sc.i === before)
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
return { root: joinAnd(pieces) };
|
|
119
|
+
}
|
|
120
|
+
/** AND the top-level pieces left around stray `)` as if the paren were not there. */
|
|
121
|
+
function joinAnd(pieces) {
|
|
122
|
+
const children = [];
|
|
123
|
+
for (const piece of pieces) {
|
|
124
|
+
for (const node of piece.kind === 'and' ? piece.children : [piece]) {
|
|
125
|
+
const last = children[children.length - 1];
|
|
126
|
+
if (node.kind === 'text' && last?.kind === 'text') {
|
|
127
|
+
last.value += ` ${node.value}`;
|
|
128
|
+
last.span[1] = node.span[1];
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
children.push(node);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (children.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
if (children.length === 1)
|
|
137
|
+
return children[0];
|
|
138
|
+
return { kind: 'and', children };
|
|
104
139
|
}
|
|
105
140
|
// orExpr := andExpr ( OR andExpr )*
|
|
106
141
|
function parseOr(sc, schema) {
|
|
@@ -218,8 +253,15 @@ function readLeaf(sc, schema) {
|
|
|
218
253
|
return node;
|
|
219
254
|
}
|
|
220
255
|
}
|
|
221
|
-
// Fallback:
|
|
256
|
+
// Fallback: a "quoted phrase", or one word (up to whitespace or a paren), as free text.
|
|
222
257
|
const wStart = sc.i;
|
|
258
|
+
if (sc.s[sc.i] === '"') {
|
|
259
|
+
const { value, end } = readQuoted(sc.s, sc.i);
|
|
260
|
+
sc.i = end;
|
|
261
|
+
if (!value)
|
|
262
|
+
return null;
|
|
263
|
+
return { kind: 'text', value, span: [wStart, end] };
|
|
264
|
+
}
|
|
223
265
|
while (sc.i < sc.s.length && !/\s/.test(sc.s[sc.i]) && sc.s[sc.i] !== '(' && sc.s[sc.i] !== ')')
|
|
224
266
|
sc.i++;
|
|
225
267
|
const word = sc.s.slice(wStart, sc.i);
|
|
@@ -270,21 +312,19 @@ function readFilter(sc, field, clauseStart, opPos, opMatch, inKeyword) {
|
|
|
270
312
|
return null;
|
|
271
313
|
}
|
|
272
314
|
sc.i = opPos + opMatch.len;
|
|
273
|
-
const
|
|
315
|
+
const rangeOp = legal.find((o) => o.id === 'range');
|
|
316
|
+
const first = readValue(sc, !!rangeOp);
|
|
274
317
|
// Range form: a..b
|
|
275
|
-
if (sc.s.startsWith('..', sc.i)) {
|
|
318
|
+
if (rangeOp && sc.s.startsWith('..', sc.i)) {
|
|
276
319
|
sc.i += 2;
|
|
277
|
-
const second = readValue(sc);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
span: [clauseStart, sc.i],
|
|
286
|
-
};
|
|
287
|
-
}
|
|
320
|
+
const second = readValue(sc, true);
|
|
321
|
+
return {
|
|
322
|
+
kind: 'filter',
|
|
323
|
+
field: field.name,
|
|
324
|
+
op: 'range',
|
|
325
|
+
values: [first.value, second.value],
|
|
326
|
+
span: [clauseStart, sc.i],
|
|
327
|
+
};
|
|
288
328
|
}
|
|
289
329
|
return {
|
|
290
330
|
kind: 'filter',
|
package/dist/query/query.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// (our fake in-memory backend). All three walk the boolean expression tree with
|
|
6
6
|
// correct precedence / grouping / short-circuiting.
|
|
7
7
|
// ─────────────────────────────────────────────────────────────────────────
|
|
8
|
+
import { quote, quoteIfNeeded } from './quotes';
|
|
8
9
|
import { operatorById } from './schema';
|
|
9
|
-
function
|
|
10
|
-
return /[
|
|
10
|
+
function quoteText(v) {
|
|
11
|
+
return /[()"]|^'/.test(v) ? quote(v) : v;
|
|
11
12
|
}
|
|
12
13
|
// ── Serialiser (AST → canonical textual query, round-trips through parse) ────
|
|
13
14
|
/** Precedence rank: higher binds tighter. Leaves are atomic. */
|
|
@@ -33,7 +34,7 @@ function serializeBare(node) {
|
|
|
33
34
|
case 'filter':
|
|
34
35
|
return serializeFilter(node);
|
|
35
36
|
case 'text':
|
|
36
|
-
return node.value;
|
|
37
|
+
return quoteText(node.value);
|
|
37
38
|
case 'not':
|
|
38
39
|
// Child must bind at least as tight as NOT, else parenthesise.
|
|
39
40
|
return `NOT ${serializeNode(node.child, 3)}`;
|
|
@@ -70,13 +71,16 @@ const SQL_OP = {
|
|
|
70
71
|
function sqlLiteral(s) {
|
|
71
72
|
return `'${s.replace(/'/g, "''")}'`;
|
|
72
73
|
}
|
|
74
|
+
function sqlLike(v) {
|
|
75
|
+
return `${sqlLiteral(`%${v.replace(/[\\%_]/g, '\\$&')}%`)} ESCAPE '\\'`;
|
|
76
|
+
}
|
|
73
77
|
function filterToSql(f) {
|
|
74
78
|
const col = f.field;
|
|
75
79
|
switch (f.op) {
|
|
76
80
|
case 'contains':
|
|
77
|
-
return `${col} ILIKE
|
|
81
|
+
return `${col} ILIKE ${sqlLike(f.values[0] ?? '')}`;
|
|
78
82
|
case 'not_contains':
|
|
79
|
-
return `${col} NOT ILIKE
|
|
83
|
+
return `${col} NOT ILIKE ${sqlLike(f.values[0] ?? '')}`;
|
|
80
84
|
case 'in':
|
|
81
85
|
return `${col} IN (${f.values.map(sqlLiteral).join(', ')})`;
|
|
82
86
|
case 'range':
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const QUOTES: Set<string>;
|
|
2
|
+
export declare function isBoundary(c: string | undefined): boolean;
|
|
3
|
+
/** True when a quote character at index `i` of `s` opens a quoted string. */
|
|
4
|
+
export declare function quoteOpensAt(s: string, i: number): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Walk `s` up to `end` tracking quote state; returns the quote character still
|
|
7
|
+
* open at `end`, or null. Escaped quotes do not close the string.
|
|
8
|
+
*/
|
|
9
|
+
export declare function openQuoteAt(s: string, end: number): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Read a quoted string starting at the opening quote `s[start]`. Returns the
|
|
12
|
+
* unescaped value and the index just past the closing quote (or the end of
|
|
13
|
+
* input when it is missing).
|
|
14
|
+
*/
|
|
15
|
+
export declare function readQuoted(s: string, start: number): {
|
|
16
|
+
value: string;
|
|
17
|
+
end: number;
|
|
18
|
+
};
|
|
19
|
+
/** Wrap `v` in double quotes, escaping what the reader needs to see escaped. */
|
|
20
|
+
export declare function quote(v: string): string;
|
|
21
|
+
/** Quote a value unless it survives the parser bare. */
|
|
22
|
+
export declare function quoteIfNeeded(v: string): string;
|
|
23
|
+
/** Strip surrounding quotes (a missing closing quote is tolerated) and unescape. */
|
|
24
|
+
export declare function unquote(v: string): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Quoting rules shared by the parser, the serialiser and the editor helpers.
|
|
2
|
+
//
|
|
3
|
+
// A quote opens a string only where a value may start: at the start of a token
|
|
4
|
+
// or right after an operator (`title:"…"`). `"` also opens a free-text phrase at
|
|
5
|
+
// a token start; `'` does not, so `don't` and `'tis` stay plain words. Inside a
|
|
6
|
+
// quoted string `\"` (or `\'` under `'`) and `\\` are escapes; any other
|
|
7
|
+
// backslash is literal, so `"C:\foo"` keeps its backslash.
|
|
8
|
+
export const QUOTES = new Set(['"', "'"]);
|
|
9
|
+
const OP_TAIL = new Set([':', '=', '<', '>']);
|
|
10
|
+
export function isBoundary(c) {
|
|
11
|
+
return c === undefined || /\s/.test(c) || c === '(' || c === ')' || c === ',';
|
|
12
|
+
}
|
|
13
|
+
/** True when a quote character at index `i` of `s` opens a quoted string. */
|
|
14
|
+
export function quoteOpensAt(s, i) {
|
|
15
|
+
const c = s[i];
|
|
16
|
+
if (!QUOTES.has(c))
|
|
17
|
+
return false;
|
|
18
|
+
const prev = s[i - 1];
|
|
19
|
+
if (OP_TAIL.has(prev))
|
|
20
|
+
return true;
|
|
21
|
+
return c === '"' && isBoundary(prev);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Walk `s` up to `end` tracking quote state; returns the quote character still
|
|
25
|
+
* open at `end`, or null. Escaped quotes do not close the string.
|
|
26
|
+
*/
|
|
27
|
+
export function openQuoteAt(s, end) {
|
|
28
|
+
let quote = null;
|
|
29
|
+
for (let i = 0; i < end; i++) {
|
|
30
|
+
const c = s[i];
|
|
31
|
+
if (quote) {
|
|
32
|
+
if (c === '\\' && (s[i + 1] === quote || s[i + 1] === '\\'))
|
|
33
|
+
i++;
|
|
34
|
+
else if (c === quote)
|
|
35
|
+
quote = null;
|
|
36
|
+
}
|
|
37
|
+
else if (quoteOpensAt(s, i)) {
|
|
38
|
+
quote = c;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return quote;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read a quoted string starting at the opening quote `s[start]`. Returns the
|
|
45
|
+
* unescaped value and the index just past the closing quote (or the end of
|
|
46
|
+
* input when it is missing).
|
|
47
|
+
*/
|
|
48
|
+
export function readQuoted(s, start) {
|
|
49
|
+
const quote = s[start];
|
|
50
|
+
let value = '';
|
|
51
|
+
let i = start + 1;
|
|
52
|
+
while (i < s.length && s[i] !== quote) {
|
|
53
|
+
if (s[i] === '\\' && (s[i + 1] === quote || s[i + 1] === '\\'))
|
|
54
|
+
i++;
|
|
55
|
+
value += s[i];
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
if (s[i] === quote)
|
|
59
|
+
i++;
|
|
60
|
+
return { value, end: i };
|
|
61
|
+
}
|
|
62
|
+
/** Wrap `v` in double quotes, escaping what the reader needs to see escaped. */
|
|
63
|
+
export function quote(v) {
|
|
64
|
+
const full = /\\\\|\\"|\\$/.test(v);
|
|
65
|
+
const body = (full ? v.replace(/\\/g, '\\\\') : v).replace(/"/g, '\\"');
|
|
66
|
+
return `"${body}"`;
|
|
67
|
+
}
|
|
68
|
+
const NEEDS_QUOTE = /[\s,()"]|\.\.|^'/;
|
|
69
|
+
/** Quote a value unless it survives the parser bare. */
|
|
70
|
+
export function quoteIfNeeded(v) {
|
|
71
|
+
return v === '' || NEEDS_QUOTE.test(v) ? quote(v) : v;
|
|
72
|
+
}
|
|
73
|
+
/** Strip surrounding quotes (a missing closing quote is tolerated) and unescape. */
|
|
74
|
+
export function unquote(v) {
|
|
75
|
+
if (!QUOTES.has(v[0]))
|
|
76
|
+
return v;
|
|
77
|
+
return readQuoted(v, 0).value;
|
|
78
|
+
}
|
package/dist/query/single.js
CHANGED
|
@@ -4,10 +4,8 @@
|
|
|
4
4
|
// the field's own provider, and the equivalent query is synthesised only for
|
|
5
5
|
// the `inline` / `below` snippets and the `onchange` AST.
|
|
6
6
|
// ─────────────────────────────────────────────────────────────────────────
|
|
7
|
+
import { quoteIfNeeded } from './quotes';
|
|
7
8
|
import { defaultOperator, findField, resolveValues } from './schema';
|
|
8
|
-
function quoteIfNeeded(v) {
|
|
9
|
-
return /[\s,()]/.test(v) ? `"${v}"` : v;
|
|
10
|
-
}
|
|
11
9
|
/**
|
|
12
10
|
* The textual query a bare `value` stands for, using `key`'s default operator —
|
|
13
11
|
* `"/srv/my app"` under key `cwd` becomes `cwd:"/srv/my app"`. Empty values (and
|
package/dist/query/suggest.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// This is pure logic, deliberately UI-free so the Svelte organism stays dumb.
|
|
8
8
|
// ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
import { quoteIfNeeded, quoteOpensAt, unquote } from './quotes';
|
|
9
10
|
import { findField, operatorsFor, resolveValues, } from './schema';
|
|
10
11
|
// Longest first so `>=`/`!:` win over `>`/`:`.
|
|
11
12
|
const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
|
|
@@ -15,46 +16,44 @@ const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
|
|
|
15
16
|
* (`album:"ok computer`) stays one token and keeps the caret in the value step
|
|
16
17
|
* rather than collapsing to an empty token (which would wrongly re-offer
|
|
17
18
|
* fields). Breaking on parens means `(artist:` offers operators for a clean
|
|
18
|
-
* `artist`, and autocomplete fires at the right spot inside a group.
|
|
19
|
+
* `artist`, and autocomplete fires at the right spot inside a group. A quote
|
|
20
|
+
* only opens where a value can start, so `don't` is a plain word.
|
|
19
21
|
*/
|
|
20
|
-
function
|
|
22
|
+
function isTokenBoundary(c) {
|
|
21
23
|
return /\s/.test(c) || c === '(' || c === ')';
|
|
22
24
|
}
|
|
23
25
|
export function activeToken(s, pos) {
|
|
24
|
-
// Walk from the start so we can track quote state; the last unquoted
|
|
25
|
-
// boundary before the caret is the token start.
|
|
26
26
|
let start = 0;
|
|
27
|
-
let
|
|
28
|
-
|
|
29
|
-
for (let i = 0; i < pos; i++) {
|
|
27
|
+
let q = null;
|
|
28
|
+
const step = (i) => {
|
|
30
29
|
const c = s[i];
|
|
31
|
-
if (
|
|
30
|
+
if (q) {
|
|
31
|
+
if (c === '\\' && (s[i + 1] === q || s[i + 1] === '\\'))
|
|
32
|
+
return i + 2;
|
|
32
33
|
if (c === q)
|
|
33
|
-
|
|
34
|
+
q = null;
|
|
34
35
|
}
|
|
35
|
-
else if (
|
|
36
|
-
inQuote = true;
|
|
36
|
+
else if (quoteOpensAt(s, i)) {
|
|
37
37
|
q = c;
|
|
38
38
|
}
|
|
39
|
-
else if (
|
|
39
|
+
else if (isTokenBoundary(c)) {
|
|
40
40
|
start = i + 1;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
return i + 1;
|
|
43
|
+
};
|
|
44
|
+
// Walk from the start so we can track quote state; the last unquoted
|
|
45
|
+
// boundary before the caret is the token start.
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < pos)
|
|
48
|
+
i = step(i);
|
|
43
49
|
// Extend forward through the rest of the token, still respecting quotes.
|
|
44
50
|
let end = pos;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (!
|
|
51
|
+
i = pos;
|
|
52
|
+
while (i < s.length) {
|
|
53
|
+
if (!q && isTokenBoundary(s[i]))
|
|
48
54
|
break;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
inQuote = false;
|
|
52
|
-
}
|
|
53
|
-
else if (c === '"' || c === "'") {
|
|
54
|
-
inQuote = true;
|
|
55
|
-
q = c;
|
|
56
|
-
}
|
|
57
|
-
end = i + 1;
|
|
55
|
+
i = step(i);
|
|
56
|
+
end = i;
|
|
58
57
|
}
|
|
59
58
|
return { start, end, raw: s.slice(start, end) };
|
|
60
59
|
}
|
|
@@ -83,12 +82,6 @@ function splitToken(raw) {
|
|
|
83
82
|
value: raw.slice(best + bestCode.length),
|
|
84
83
|
};
|
|
85
84
|
}
|
|
86
|
-
function unquote(v) {
|
|
87
|
-
return v.replace(/^["']|["']$/g, '');
|
|
88
|
-
}
|
|
89
|
-
function quoteIfNeeded(v) {
|
|
90
|
-
return /[\s,()]/.test(v) ? `"${v}"` : v;
|
|
91
|
-
}
|
|
92
85
|
function fieldSuggestions(schema, frag, span) {
|
|
93
86
|
const q = frag.toLowerCase();
|
|
94
87
|
const items = schema.fields
|
|
@@ -125,7 +118,7 @@ function operatorSuggestions(field, span) {
|
|
|
125
118
|
}
|
|
126
119
|
function buildValueState(field, opCode, span, options) {
|
|
127
120
|
const items = options.map((o) => {
|
|
128
|
-
const insert = `${field.name}${opCode}${quoteIfNeeded(o.value)}`;
|
|
121
|
+
const insert = `${field.name}${opCode}${o.value === '' ? '' : quoteIfNeeded(o.value)}`;
|
|
129
122
|
return {
|
|
130
123
|
label: o.label,
|
|
131
124
|
hint: o.hint,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { browser } from '../env';
|
|
2
|
+
import { readStorage, writeStorage } from './storage';
|
|
2
3
|
// OPT-IN. Most apps don't need this: the kit is rem-based and never resets the
|
|
3
4
|
// root font-size, so the user's browser/OS font-size preference and browser
|
|
4
5
|
// zoom already scale the UI correctly. Reach for this control only in
|
|
@@ -28,7 +29,7 @@ class FontScale {
|
|
|
28
29
|
current = $derived(levelById(this.levelId).value);
|
|
29
30
|
constructor() {
|
|
30
31
|
if (browser) {
|
|
31
|
-
const raw =
|
|
32
|
+
const raw = readStorage(KEY);
|
|
32
33
|
if (raw && SCALE_LEVELS.some((l) => l.id === raw))
|
|
33
34
|
this.levelId = raw;
|
|
34
35
|
this.apply();
|
|
@@ -42,7 +43,7 @@ class FontScale {
|
|
|
42
43
|
set(id) {
|
|
43
44
|
this.levelId = SCALE_LEVELS.some((l) => l.id === id) ? id : DEFAULT_LEVEL;
|
|
44
45
|
if (browser)
|
|
45
|
-
|
|
46
|
+
writeStorage(KEY, this.levelId);
|
|
46
47
|
this.apply();
|
|
47
48
|
}
|
|
48
49
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// localStorage throws SecurityError when site data is blocked (Safari private
|
|
2
|
+
// mode, sandboxed or third-party iframes); callers fall back to their defaults.
|
|
3
|
+
export function readStorage(key) {
|
|
4
|
+
try {
|
|
5
|
+
return localStorage.getItem(key);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function writeStorage(key, value) {
|
|
12
|
+
try {
|
|
13
|
+
localStorage.setItem(key, value);
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { browser } from '../env';
|
|
2
2
|
import { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, preferenceFrom, resolveTheme, } from '../theme-mode';
|
|
3
|
+
import { readStorage, writeStorage } from './storage';
|
|
3
4
|
// Theme registry. Built-ins live in THEMES (+ one [data-theme="id"] block in
|
|
4
5
|
// styles/themes.css); consumers append their own with theme.register() and ship
|
|
5
6
|
// the matching block in their own stylesheet. `themeColor` drives the mobile
|
|
@@ -87,7 +88,7 @@ class Theme {
|
|
|
87
88
|
this.systemDark = e.matches;
|
|
88
89
|
this.paint();
|
|
89
90
|
});
|
|
90
|
-
this.saved =
|
|
91
|
+
this.saved = readStorage(KEY);
|
|
91
92
|
this.resolve();
|
|
92
93
|
}
|
|
93
94
|
}
|
|
@@ -151,12 +152,8 @@ class Theme {
|
|
|
151
152
|
}
|
|
152
153
|
persist() {
|
|
153
154
|
this.saved = JSON.stringify(this.pref);
|
|
154
|
-
if (browser)
|
|
155
|
-
|
|
156
|
-
localStorage.setItem(KEY, this.saved);
|
|
157
|
-
}
|
|
158
|
-
catch { }
|
|
159
|
-
}
|
|
155
|
+
if (browser)
|
|
156
|
+
writeStorage(KEY, this.saved);
|
|
160
157
|
this.onchange?.(this.pref);
|
|
161
158
|
}
|
|
162
159
|
apply() {
|
package/package.json
CHANGED