@warkypublic/svelix 0.1.34 → 0.1.36
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/dist/components/GlobalStateStore/GlobalStateStore.d.ts +2 -2
- package/dist/components/GlobalStateStore/GlobalStateStore.js +27 -18
- package/dist/components/GlobalStateStore/GlobalStateStore.mdx +209 -0
- package/dist/components/Gridler/components/Gridler.svelte +26 -8
- package/dist/components/Gridler/components/GridlerCanvas.svelte +30 -61
- package/dist/components/Gridler/components/GridlerCanvas.svelte.d.ts +1 -0
- package/dist/components/Gridler/components/GridlerFull.svelte +70 -26
- package/dist/components/Gridler/renderers/ImageRenderer.js +14 -4
- package/dist/components/Gridler/renderers/MarkdownRenderer.d.ts +3 -2
- package/dist/components/Gridler/renderers/MarkdownRenderer.js +12 -3
- package/dist/components/Gridler/renderers/PercentageRenderer.js +11 -6
- package/dist/components/Gridler/renderers/index.js +12 -4
- package/dist/components/Gridler/renderers/shared.d.ts +5 -0
- package/dist/components/Gridler/renderers/shared.js +15 -5
- package/dist/components/Gridler/types.d.ts +16 -0
- package/package.json +1 -1
|
@@ -9,14 +9,14 @@ type GlobalStateStoreApi = {
|
|
|
9
9
|
declare const createGlobalStateStore: () => GlobalStateStoreApi;
|
|
10
10
|
declare const GlobalStateStore: GlobalStateStoreApi;
|
|
11
11
|
declare const languageStore: Readable<SupportedLanguage>;
|
|
12
|
+
declare const isLoggedInStore: Readable<boolean>;
|
|
12
13
|
declare function useGlobalStateStore(): Readable<GlobalStateStoreType>;
|
|
13
14
|
declare function useGlobalStateStore<T>(selector: (state: GlobalStateStoreType) => T): Readable<T>;
|
|
14
15
|
declare const setApiURL: (url: string) => void;
|
|
15
16
|
declare const getApiURL: () => string;
|
|
16
17
|
declare const getAuthToken: () => string;
|
|
17
|
-
declare const isLoggedIn: () => boolean;
|
|
18
18
|
declare const setAuthToken: (token: string) => void;
|
|
19
19
|
declare const GetGlobalState: () => GlobalStateStoreType;
|
|
20
20
|
declare const setLanguage: (lang: SupportedLanguage) => void;
|
|
21
21
|
export type { GlobalStateStoreApi };
|
|
22
|
-
export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore,
|
|
22
|
+
export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedInStore, languageStore, setApiURL, setAuthToken, setLanguage, useGlobalStateStore, };
|
|
@@ -102,12 +102,29 @@ const hasSessionValidationSignal = (session, user) => {
|
|
|
102
102
|
user?.guid ||
|
|
103
103
|
user?.username);
|
|
104
104
|
};
|
|
105
|
+
const computeIsLoggedIn = (session) => {
|
|
106
|
+
if (!session.validated || !session.loggedIn || !session.authToken) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return !isSessionExpired(session);
|
|
110
|
+
};
|
|
105
111
|
const createGlobalStateStore = () => {
|
|
106
112
|
const initialState = createInitialState();
|
|
107
113
|
let isStorageInitialized = false;
|
|
108
114
|
let initializationPromise = null;
|
|
109
115
|
let operationLock = Promise.resolve();
|
|
110
116
|
let hasAutoValidated = false;
|
|
117
|
+
let validationScheduled = false;
|
|
118
|
+
const scheduleValidation = () => {
|
|
119
|
+
if (validationScheduled)
|
|
120
|
+
return;
|
|
121
|
+
validationScheduled = true;
|
|
122
|
+
waitForInitialization()
|
|
123
|
+
.then(() => withOperationLock(() => fetchDataInternal()))
|
|
124
|
+
.finally(() => {
|
|
125
|
+
validationScheduled = false;
|
|
126
|
+
});
|
|
127
|
+
};
|
|
111
128
|
const store = writable(undefined);
|
|
112
129
|
const getState = () => get(store);
|
|
113
130
|
const setState = (partial, replace = false) => {
|
|
@@ -126,7 +143,7 @@ const createGlobalStateStore = () => {
|
|
|
126
143
|
hasAutoValidated = true;
|
|
127
144
|
const session = after.session;
|
|
128
145
|
if (hasSessionCredentials(session) && !session.validated) {
|
|
129
|
-
|
|
146
|
+
scheduleValidation();
|
|
130
147
|
}
|
|
131
148
|
}
|
|
132
149
|
};
|
|
@@ -258,16 +275,7 @@ const createGlobalStateStore = () => {
|
|
|
258
275
|
await waitForInitialization();
|
|
259
276
|
return withOperationLock(() => fetchDataInternal(url));
|
|
260
277
|
},
|
|
261
|
-
isLoggedIn: () =>
|
|
262
|
-
const session = getState().session;
|
|
263
|
-
if (!session.validated || !session.loggedIn || !session.authToken) {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
if (isSessionExpired(session)) {
|
|
267
|
-
return false;
|
|
268
|
-
}
|
|
269
|
-
return true;
|
|
270
|
-
},
|
|
278
|
+
isLoggedIn: () => computeIsLoggedIn(getState().session),
|
|
271
279
|
login: async (authToken, user) => {
|
|
272
280
|
await waitForInitialization();
|
|
273
281
|
return withOperationLock(async () => {
|
|
@@ -463,9 +471,6 @@ const createGlobalStateStore = () => {
|
|
|
463
471
|
...loadedState.session,
|
|
464
472
|
connected: true,
|
|
465
473
|
loading: false,
|
|
466
|
-
loggedIn: hasPersistedSession
|
|
467
|
-
? false
|
|
468
|
-
: loadedState.session?.loggedIn ?? current.session.loggedIn,
|
|
469
474
|
validated: hasPersistedSession
|
|
470
475
|
? false
|
|
471
476
|
: loadedState.session?.validated ?? current.session.validated,
|
|
@@ -484,6 +489,12 @@ const createGlobalStateStore = () => {
|
|
|
484
489
|
if (!isStorageInitialized) {
|
|
485
490
|
return;
|
|
486
491
|
}
|
|
492
|
+
if (hasSessionCredentials(state.session) &&
|
|
493
|
+
!state.session.validated &&
|
|
494
|
+
!computeIsLoggedIn(state.session) &&
|
|
495
|
+
typeof state.onFetchSession === "function") {
|
|
496
|
+
scheduleValidation();
|
|
497
|
+
}
|
|
487
498
|
saveStorage(toPersistedState(state)).catch((e) => {
|
|
488
499
|
console.error("Error saving storage:", e);
|
|
489
500
|
});
|
|
@@ -499,6 +510,7 @@ const GlobalStateStoreReadable = {
|
|
|
499
510
|
subscribe: GlobalStateStore.subscribe,
|
|
500
511
|
};
|
|
501
512
|
const languageStore = derived(GlobalStateStoreReadable, (state) => state.user.language ?? "en");
|
|
513
|
+
const isLoggedInStore = derived(GlobalStateStoreReadable, (state) => computeIsLoggedIn(state.session));
|
|
502
514
|
initTranslation(languageStore);
|
|
503
515
|
function useGlobalStateStore(selector) {
|
|
504
516
|
if (!selector) {
|
|
@@ -515,9 +527,6 @@ const getApiURL = () => {
|
|
|
515
527
|
const getAuthToken = () => {
|
|
516
528
|
return GlobalStateStore.getState().session.authToken ?? "";
|
|
517
529
|
};
|
|
518
|
-
const isLoggedIn = () => {
|
|
519
|
-
return GlobalStateStore.getState().isLoggedIn();
|
|
520
|
-
};
|
|
521
530
|
const setAuthToken = (token) => {
|
|
522
531
|
GlobalStateStore.getState().setAuthToken(token);
|
|
523
532
|
};
|
|
@@ -527,4 +536,4 @@ const GetGlobalState = () => {
|
|
|
527
536
|
const setLanguage = (lang) => {
|
|
528
537
|
GlobalStateStore.getState().setLanguage(lang);
|
|
529
538
|
};
|
|
530
|
-
export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore,
|
|
539
|
+
export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedInStore, languageStore, setApiURL, setAuthToken, setLanguage, useGlobalStateStore, };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# GlobalStateStore
|
|
2
|
+
|
|
3
|
+
Singleton Svelte store for app-wide authenticated state. Persists all slices to `localStorage` and re-validates credentials on every page load.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Wrap your app with `GlobalStateStoreProvider` and wire up the three server callbacks:
|
|
10
|
+
|
|
11
|
+
```svelte
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import { GlobalStateStoreProvider } from '@warkypublic/svelix';
|
|
14
|
+
|
|
15
|
+
const onLogin = async (state) => {
|
|
16
|
+
const res = await api.post('/auth/login', { token: state.session.authToken });
|
|
17
|
+
return { session: res.session, user: res.user };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const onLogout = async () => {
|
|
21
|
+
await api.post('/auth/logout');
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const onFetchSession = async (state) => {
|
|
25
|
+
const res = await api.get('/app/bootstrap');
|
|
26
|
+
return { session: res.session, user: res.user, navigation: res.nav, program: res.program };
|
|
27
|
+
};
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<GlobalStateStoreProvider {onLogin} {onLogout} {onFetchSession}>
|
|
31
|
+
{@render children()}
|
|
32
|
+
</GlobalStateStoreProvider>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Provider props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Default | Description |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| `apiURL` | `string` | — | Sets `session.apiURL` on mount |
|
|
40
|
+
| `autoFetch` | `boolean` | `true` | Enables automatic `fetchData` calls |
|
|
41
|
+
| `fetchOnMount` | `boolean` | `true` | Calls `fetchData` once after mount |
|
|
42
|
+
| `onFetchSession` | `fn` | — | Called by `fetchData`; must return state slice or void |
|
|
43
|
+
| `onLogin` | `fn` | — | Called after credentials are set; return confirms the session |
|
|
44
|
+
| `onLogout` | `fn` | — | Called during logout |
|
|
45
|
+
| `program` | `Partial<ProgramState>` | — | Merged into `program` slice on mount |
|
|
46
|
+
| `throttleMs` | `number` | `0` | Minimum ms between `fetchData` calls |
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## State slices
|
|
51
|
+
|
|
52
|
+
### `session`
|
|
53
|
+
|
|
54
|
+
| Field | Type | Description |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `apiURL` | `string` | Base API URL |
|
|
57
|
+
| `authToken` | `string` | Bearer token |
|
|
58
|
+
| `loggedIn` | `boolean` | Persisted login intent |
|
|
59
|
+
| `validated` | `boolean` | Server has confirmed the session this load |
|
|
60
|
+
| `expiryDate` | `string` | ISO date; `isLoggedIn` returns false after this |
|
|
61
|
+
| `connected` | `boolean` | Last fetch succeeded |
|
|
62
|
+
| `loading` | `boolean` | Fetch in progress |
|
|
63
|
+
| `error` | `string` | Last fetch error message |
|
|
64
|
+
|
|
65
|
+
### `user`
|
|
66
|
+
|
|
67
|
+
`guid`, `username`, `email`, `fullNames`, `language`, `isAdmin`, `avatarUrl`, `theme`, `parameters`
|
|
68
|
+
|
|
69
|
+
### `owner`
|
|
70
|
+
|
|
71
|
+
`id`, `guid`, `name`, `logo`, `settings`, `theme`
|
|
72
|
+
|
|
73
|
+
### `program`
|
|
74
|
+
|
|
75
|
+
`guid`, `name`, `slug`, `environment`, `version`, `controls`, `globals`, `database`, `meta`
|
|
76
|
+
|
|
77
|
+
### `layout`
|
|
78
|
+
|
|
79
|
+
`topBar`, `leftBar`, `rightBar`, `bottomBar` — each is `{ open, pinned, size, collapsed, menuItems }`
|
|
80
|
+
|
|
81
|
+
### `navigation`
|
|
82
|
+
|
|
83
|
+
`menu: MenuItem[]`, `currentPage: { title, path, breadcrumbs, meta }`
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Auth lifecycle
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
login(token)
|
|
91
|
+
→ sets authToken, loggedIn:false, validated:false
|
|
92
|
+
→ calls onLogin ──► server confirms → loggedIn:true, validated:true
|
|
93
|
+
→ calls fetchData (onFetchSession) → merges server state
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
logout()
|
|
98
|
+
→ clears all auth state, loading:true
|
|
99
|
+
→ calls onLogout
|
|
100
|
+
→ calls fetchData (onFetchSession)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
fetchData(url?)
|
|
105
|
+
→ no-op if onFetchSession not registered
|
|
106
|
+
→ calls onFetchSession
|
|
107
|
+
→ validates or clears session (see Session Validation below)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Session validation
|
|
113
|
+
|
|
114
|
+
Credentials from `localStorage` start each page load as `loggedIn: true, validated: false`. `isLoggedInStore` emits `false` until validated.
|
|
115
|
+
|
|
116
|
+
Validation is triggered automatically — no manual call needed — in priority order:
|
|
117
|
+
|
|
118
|
+
1. **`onFetchSession` registered** while unvalidated credentials exist (`setState` detects handler transition)
|
|
119
|
+
2. **Any state change** with unvalidated credentials + handler present (`store.subscribe`)
|
|
120
|
+
|
|
121
|
+
`fetchData` clears the session when the server response indicates invalidity:
|
|
122
|
+
|
|
123
|
+
| Condition | Result |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `onFetchSession` not registered | No-op, session unchanged |
|
|
126
|
+
| Server returns no session/user fields | Session preserved, `validated` stays `false` |
|
|
127
|
+
| Server returns `loggedIn: false` | Session cleared |
|
|
128
|
+
| Server returns a different `authToken` | Session cleared |
|
|
129
|
+
| Server returns a different `user.guid` or `username` | Session cleared |
|
|
130
|
+
| `expiryDate` is in the past | Session cleared |
|
|
131
|
+
| Server confirms credentials | `validated: true`, session kept |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Reactive usage (Svelte components)
|
|
136
|
+
|
|
137
|
+
```svelte
|
|
138
|
+
<script lang="ts">
|
|
139
|
+
import { isLoggedInStore, useGlobalStateStore } from '@warkypublic/svelix';
|
|
140
|
+
|
|
141
|
+
const username = useGlobalStateStore((s) => s.user.username);
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
{#if $isLoggedInStore}
|
|
145
|
+
<p>Welcome, {$username}</p>
|
|
146
|
+
{/if}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Reactive stores
|
|
150
|
+
|
|
151
|
+
| Export | Type | Value |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `isLoggedInStore` | `Readable<boolean>` | `true` only when `loggedIn && validated && !expired` |
|
|
154
|
+
| `languageStore` | `Readable<'en' \| 'af'>` | `user.language` |
|
|
155
|
+
| `useGlobalStateStore(selector?)` | `Readable<T>` | Derived slice of the full store |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Imperative usage (outside components)
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { GlobalStateStore, getAuthToken, getApiURL, setAuthToken, setApiURL } from '@warkypublic/svelix';
|
|
163
|
+
|
|
164
|
+
const state = GlobalStateStore.getState();
|
|
165
|
+
|
|
166
|
+
await state.login('my-token');
|
|
167
|
+
await state.logout();
|
|
168
|
+
await state.fetchData();
|
|
169
|
+
|
|
170
|
+
state.isLoggedIn(); // boolean — checks validated + loggedIn + authToken + expiry
|
|
171
|
+
state.setAuthToken('token');
|
|
172
|
+
state.setApiURL('https://api.example.com');
|
|
173
|
+
state.setUser({ username: 'alice' });
|
|
174
|
+
state.setSession({ expiryDate: '2099-01-01T00:00:00.000Z' });
|
|
175
|
+
|
|
176
|
+
getAuthToken(); // shorthand
|
|
177
|
+
getApiURL();
|
|
178
|
+
setAuthToken('token');
|
|
179
|
+
setApiURL('https://api.example.com');
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Persistence
|
|
185
|
+
|
|
186
|
+
All slices are written to `localStorage` under `APP_GLO:<slice>` on every state change.
|
|
187
|
+
|
|
188
|
+
**Not persisted:** `session.connected`, `session.error`, `session.loading`, `session.validated`
|
|
189
|
+
|
|
190
|
+
On load, `validated` is forced to `false` when credentials exist. `loggedIn` is preserved as-is so the UI can optimistically render the logged-in shell while re-validation completes in the background.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Callback return shapes
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// onLogin / onLogout
|
|
198
|
+
Promise<{
|
|
199
|
+
session?: Partial<SessionState>;
|
|
200
|
+
user?: Partial<UserState>;
|
|
201
|
+
owner?: Partial<OwnerState>;
|
|
202
|
+
program?: Partial<ProgramState>;
|
|
203
|
+
} | void>
|
|
204
|
+
|
|
205
|
+
// onFetchSession (superset — also accepts layout + navigation)
|
|
206
|
+
Promise<Partial<GlobalState>>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Return only the fields that changed. Omitted fields are left as-is.
|
|
@@ -123,8 +123,13 @@
|
|
|
123
123
|
|
|
124
124
|
// ── Selection / editing state ────────────────────────────────────────────────
|
|
125
125
|
|
|
126
|
-
let currentSelection = $state<Selection>(
|
|
126
|
+
let currentSelection = $state<Selection>({ type: "none" });
|
|
127
127
|
let focusedCell = $state<Item | null>(null);
|
|
128
|
+
|
|
129
|
+
$effect(() => {
|
|
130
|
+
if (!selection || selection.type === "none") return;
|
|
131
|
+
currentSelection = selection;
|
|
132
|
+
});
|
|
128
133
|
let isEditing = $state(false);
|
|
129
134
|
let editingCell = $state<Item | null>(null);
|
|
130
135
|
|
|
@@ -211,10 +216,14 @@
|
|
|
211
216
|
}
|
|
212
217
|
|
|
213
218
|
if (lines.length > 0) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
try {
|
|
220
|
+
await navigator.clipboard.writeText(lines.join("\n"));
|
|
221
|
+
canvasComponent?.setAnnouncement(
|
|
222
|
+
`Copied ${lines.length} row${lines.length > 1 ? "s" : ""} to clipboard`,
|
|
223
|
+
);
|
|
224
|
+
} catch {
|
|
225
|
+
canvasComponent?.setAnnouncement("Clipboard access denied");
|
|
226
|
+
}
|
|
218
227
|
}
|
|
219
228
|
}
|
|
220
229
|
|
|
@@ -222,7 +231,13 @@
|
|
|
222
231
|
if (resolvedReadonly) return;
|
|
223
232
|
const fc = canvasComponent?.getFocusedCell() ?? focusedCell;
|
|
224
233
|
if (!fc) return;
|
|
225
|
-
|
|
234
|
+
let text: string;
|
|
235
|
+
try {
|
|
236
|
+
text = await navigator.clipboard.readText();
|
|
237
|
+
} catch {
|
|
238
|
+
canvasComponent?.setAnnouncement("Clipboard access denied");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
226
241
|
const pastedRows = text.split("\n").map((line) => line.split("\t"));
|
|
227
242
|
const [startCol, startRow] = fc;
|
|
228
243
|
|
|
@@ -413,6 +428,7 @@
|
|
|
413
428
|
width={typeof width === "number" ? `${width}px` : (width ?? "100%")}
|
|
414
429
|
height={typeof height === "number" ? `${height}px` : (height ?? "400px")}
|
|
415
430
|
{mergedTheme}
|
|
431
|
+
{isDarkMode}
|
|
416
432
|
{headerHeight}
|
|
417
433
|
{rowHeight}
|
|
418
434
|
readonly={resolvedReadonly}
|
|
@@ -460,8 +476,10 @@
|
|
|
460
476
|
onRowContextMenu={(row) => {
|
|
461
477
|
onRowContextMenu?.(row);
|
|
462
478
|
}}
|
|
463
|
-
onColumnResized={(col,
|
|
464
|
-
|
|
479
|
+
onColumnResized={(col, newWidth) => {
|
|
480
|
+
columns[col].width = newWidth;
|
|
481
|
+
columns[col].grow = 0;
|
|
482
|
+
onGridEvent?.("column_resized", undefined, columns[col], undefined, { width: newWidth });
|
|
465
483
|
}}
|
|
466
484
|
onGridResize={(width, height) => {
|
|
467
485
|
onGridEvent?.("resize", undefined, undefined, undefined, { width, height });
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
width: string;
|
|
38
38
|
height: string;
|
|
39
39
|
mergedTheme: GridlerTheme;
|
|
40
|
+
isDarkMode?: boolean;
|
|
40
41
|
headerHeight?: number;
|
|
41
42
|
rowHeight?: number;
|
|
42
43
|
readonly?: boolean;
|
|
@@ -92,6 +93,7 @@
|
|
|
92
93
|
width,
|
|
93
94
|
height,
|
|
94
95
|
mergedTheme,
|
|
96
|
+
isDarkMode: isDarkModeProp = false,
|
|
95
97
|
headerHeight = 36,
|
|
96
98
|
rowHeight = 34,
|
|
97
99
|
readonly = false,
|
|
@@ -135,7 +137,6 @@
|
|
|
135
137
|
|
|
136
138
|
// ── Internal state ───────────────────────────────────────────────────────────
|
|
137
139
|
|
|
138
|
-
let isDarkMode = $state(false);
|
|
139
140
|
let scrollX = $state(0);
|
|
140
141
|
let scrollY = $state(0);
|
|
141
142
|
let currentSelection = $state<Selection>({ type: "none" });
|
|
@@ -143,8 +144,8 @@
|
|
|
143
144
|
let isDragging = $state(false);
|
|
144
145
|
let dragStart = $state<Item | null>(null);
|
|
145
146
|
let resizingColumn = $state<number | null>(null);
|
|
147
|
+
let resizeWidth = $state<number | null>(null);
|
|
146
148
|
let hoverResizeCol = $state<number | null>(null);
|
|
147
|
-
let resizeVersion = $state(0);
|
|
148
149
|
let draggingHeaderCol = $state<number | null>(null);
|
|
149
150
|
let dragHeaderCurrentX = $state(0);
|
|
150
151
|
let announcement = $state("");
|
|
@@ -194,13 +195,17 @@
|
|
|
194
195
|
);
|
|
195
196
|
|
|
196
197
|
const effectiveColumns: GridlerColumn[] = $derived.by(() => {
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
const cols = resizingColumn !== null && resizeWidth !== null
|
|
199
|
+
? columns.map((c, i) => i === resizingColumn ? { ...c, width: resizeWidth, grow: 0 } : c)
|
|
200
|
+
: columns;
|
|
201
|
+
return computeEffectiveColumns(cols, containerWidth, rowMarkerWidth);
|
|
199
202
|
});
|
|
200
203
|
|
|
201
204
|
const totalWidth = $derived.by(() => {
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
const cols = resizingColumn !== null && resizeWidth !== null
|
|
206
|
+
? columns.map((c, i) => i === resizingColumn ? { ...c, width: resizeWidth } : c)
|
|
207
|
+
: columns;
|
|
208
|
+
return cols.reduce((sum, col) => sum + col.width, 0) + rowMarkerWidth;
|
|
204
209
|
});
|
|
205
210
|
|
|
206
211
|
const totalHeight = $derived(getTotalHeight(rows, rowHeight));
|
|
@@ -257,45 +262,6 @@
|
|
|
257
262
|
}
|
|
258
263
|
});
|
|
259
264
|
|
|
260
|
-
// ── Dark mode detection ───────────────────────────────────────────────────────
|
|
261
|
-
|
|
262
|
-
function computeIsDarkMode(): boolean {
|
|
263
|
-
const root = containerRef;
|
|
264
|
-
if (root?.closest('.dark, [data-theme="dark"]')) return true;
|
|
265
|
-
if (document?.documentElement?.classList.contains("dark")) return true;
|
|
266
|
-
if (document?.body?.classList.contains("dark")) return true;
|
|
267
|
-
if (document?.documentElement?.getAttribute("data-theme") === "dark")
|
|
268
|
-
return true;
|
|
269
|
-
if (document?.body?.getAttribute("data-theme") === "dark") return true;
|
|
270
|
-
return false;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
$effect(() => {
|
|
274
|
-
if (!containerRef) return;
|
|
275
|
-
const update = () => {
|
|
276
|
-
isDarkMode = computeIsDarkMode();
|
|
277
|
-
};
|
|
278
|
-
update();
|
|
279
|
-
|
|
280
|
-
const observed: HTMLElement[] = [];
|
|
281
|
-
let el: HTMLElement | null = containerRef;
|
|
282
|
-
while (el) {
|
|
283
|
-
observed.push(el);
|
|
284
|
-
el = el.parentElement;
|
|
285
|
-
}
|
|
286
|
-
if (document.documentElement) observed.push(document.documentElement);
|
|
287
|
-
if (document.body) observed.push(document.body);
|
|
288
|
-
|
|
289
|
-
const mo = new MutationObserver(update);
|
|
290
|
-
for (const node of observed) {
|
|
291
|
-
mo.observe(node, {
|
|
292
|
-
attributes: true,
|
|
293
|
-
attributeFilter: ["class", "data-theme"],
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
return () => mo.disconnect();
|
|
297
|
-
});
|
|
298
|
-
|
|
299
265
|
// ── Resize observer ───────────────────────────────────────────────────────────
|
|
300
266
|
|
|
301
267
|
$effect(() => {
|
|
@@ -320,16 +286,20 @@
|
|
|
320
286
|
return () => document.removeEventListener(IMAGE_LOADED_EVENT, handler);
|
|
321
287
|
});
|
|
322
288
|
|
|
289
|
+
// ── Document listener cleanup on destroy ─────────────────────────────────────
|
|
290
|
+
$effect(() => {
|
|
291
|
+
return () => {
|
|
292
|
+
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
|
293
|
+
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
|
|
323
297
|
// ── Render loop ───────────────────────────────────────────────────────────────
|
|
324
298
|
|
|
325
299
|
$effect(() => {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
void
|
|
329
|
-
void getCellContent;
|
|
330
|
-
void mergedTheme;
|
|
331
|
-
void isDarkMode;
|
|
332
|
-
void sortOrder;
|
|
300
|
+
// Track reactive dependencies that should trigger a redraw.
|
|
301
|
+
const _deps = [visibleRange, searchValue, currentSelection, getCellContent, mergedTheme, isDarkModeProp, sortOrder];
|
|
302
|
+
void _deps;
|
|
333
303
|
if (ctx && containerWidth > 0 && containerHeight > 0) {
|
|
334
304
|
scheduleDraw();
|
|
335
305
|
}
|
|
@@ -367,7 +337,7 @@
|
|
|
367
337
|
totalHeight,
|
|
368
338
|
currentSelection,
|
|
369
339
|
mergedTheme,
|
|
370
|
-
isDarkMode,
|
|
340
|
+
isDarkMode: isDarkModeProp,
|
|
371
341
|
rowMarkers: rowMarkers ?? "none",
|
|
372
342
|
draggingHeaderCol,
|
|
373
343
|
dragHeaderCurrentX,
|
|
@@ -849,9 +819,7 @@
|
|
|
849
819
|
const scrollXForResize =
|
|
850
820
|
fixedColumns > 0 && resizingColumn < fixedColumns ? 0 : scrollX;
|
|
851
821
|
const newWidth = Math.max(50, e.offsetX - baseX + scrollXForResize);
|
|
852
|
-
|
|
853
|
-
columns[resizingColumn].grow = 0;
|
|
854
|
-
resizeVersion++;
|
|
822
|
+
resizeWidth = newWidth;
|
|
855
823
|
scheduleDraw();
|
|
856
824
|
return;
|
|
857
825
|
}
|
|
@@ -955,12 +923,13 @@
|
|
|
955
923
|
scheduleDraw();
|
|
956
924
|
}
|
|
957
925
|
|
|
958
|
-
if (resizingColumn !== null) {
|
|
959
|
-
onColumnResized?.(resizingColumn,
|
|
926
|
+
if (resizingColumn !== null && resizeWidth !== null) {
|
|
927
|
+
onColumnResized?.(resizingColumn, resizeWidth);
|
|
960
928
|
}
|
|
961
929
|
isDragging = false;
|
|
962
930
|
dragStart = null;
|
|
963
931
|
resizingColumn = null;
|
|
932
|
+
resizeWidth = null;
|
|
964
933
|
}
|
|
965
934
|
|
|
966
935
|
function handleMouseLeave(e: MouseEvent) {
|
|
@@ -969,6 +938,7 @@
|
|
|
969
938
|
hoverResizeCol = null;
|
|
970
939
|
hoverMenuButton = false;
|
|
971
940
|
hoverSortCol = null;
|
|
941
|
+
pendingGridMenuOpen = false;
|
|
972
942
|
if (hoveredCell) {
|
|
973
943
|
onCellLeave?.(hoveredCell);
|
|
974
944
|
hoveredCell = null;
|
|
@@ -981,7 +951,6 @@
|
|
|
981
951
|
}
|
|
982
952
|
|
|
983
953
|
function handleWheel(e: WheelEvent) {
|
|
984
|
-
if (!hasFocus) return;
|
|
985
954
|
if (scrollRef) {
|
|
986
955
|
e.preventDefault();
|
|
987
956
|
scrollRef.scrollLeft += e.deltaX;
|
|
@@ -1079,7 +1048,7 @@
|
|
|
1079
1048
|
|
|
1080
1049
|
if (rowMarkers !== "none" && offsetX < rowMarkerWidth) {
|
|
1081
1050
|
const row = getRowFromOffsetY(offsetY);
|
|
1082
|
-
if (row >= 0 && row < rows) {
|
|
1051
|
+
if (row !== null && row >= 0 && row < rows) {
|
|
1083
1052
|
onRowToggle?.(row);
|
|
1084
1053
|
}
|
|
1085
1054
|
return;
|
|
@@ -1207,7 +1176,7 @@
|
|
|
1207
1176
|
<GridlerEditor
|
|
1208
1177
|
cell={getCellContent(editingCell)}
|
|
1209
1178
|
x={getColumnX(effectiveColumns, editingCell[0]) -
|
|
1210
|
-
scrollX +
|
|
1179
|
+
(editingCell[0] < fixedColumns ? 0 : scrollX) +
|
|
1211
1180
|
rowMarkerWidth}
|
|
1212
1181
|
y={editingCell[1] * rowHeight - scrollY + headerHeight}
|
|
1213
1182
|
width={effectiveColumns[editingCell[0]]?.width ?? 0}
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
// gridler pass-through
|
|
164
164
|
fixedColumns = 0,
|
|
165
165
|
rowMarkers,
|
|
166
|
-
showSearch
|
|
166
|
+
showSearch,
|
|
167
167
|
height = 500,
|
|
168
168
|
onColumnMoved,
|
|
169
169
|
onCellEdited,
|
|
@@ -211,10 +211,15 @@
|
|
|
211
211
|
if (data === untrack(() => lastDataRef)) return;
|
|
212
212
|
lastDataRef = data;
|
|
213
213
|
if (typeof data === "function") {
|
|
214
|
-
data()
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
214
|
+
data()
|
|
215
|
+
.then((result) => {
|
|
216
|
+
dataState = result;
|
|
217
|
+
onGridEvent?.("load");
|
|
218
|
+
})
|
|
219
|
+
.catch((e) => {
|
|
220
|
+
const msg = e instanceof Error ? e.message : "Failed to load data";
|
|
221
|
+
onLoadError?.(msg);
|
|
222
|
+
});
|
|
218
223
|
} else {
|
|
219
224
|
dataState = data.slice();
|
|
220
225
|
onGridEvent?.("load");
|
|
@@ -292,9 +297,14 @@
|
|
|
292
297
|
if (isServerMode) {
|
|
293
298
|
refreshTrigger++;
|
|
294
299
|
} else if (typeof data === "function") {
|
|
295
|
-
data()
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
data()
|
|
301
|
+
.then((result) => {
|
|
302
|
+
dataState = result;
|
|
303
|
+
})
|
|
304
|
+
.catch((e) => {
|
|
305
|
+
const msg = e instanceof Error ? e.message : "Failed to load data";
|
|
306
|
+
onLoadError?.(msg);
|
|
307
|
+
});
|
|
298
308
|
}
|
|
299
309
|
}
|
|
300
310
|
|
|
@@ -355,6 +365,7 @@
|
|
|
355
365
|
let filterPanelColTitle = $state("");
|
|
356
366
|
let filterPanelOp = $state<GridFilterOp>("contains");
|
|
357
367
|
let filterPanelValue = $state("");
|
|
368
|
+
let filterPanelValue2 = $state("");
|
|
358
369
|
|
|
359
370
|
// Snippet reference captured from the template — assigned via {(filterRenderer ??= filterForm, '')}
|
|
360
371
|
let filterRenderer = $state<Snippet<
|
|
@@ -372,6 +383,7 @@
|
|
|
372
383
|
filterPanelColTitle = colTitle;
|
|
373
384
|
filterPanelOp = existing?.op ?? "contains";
|
|
374
385
|
filterPanelValue = existing?.value ?? "";
|
|
386
|
+
filterPanelValue2 = existing?.value2 ?? "";
|
|
375
387
|
menu?.show("gridler-filter-panel", {
|
|
376
388
|
x,
|
|
377
389
|
y,
|
|
@@ -387,9 +399,13 @@
|
|
|
387
399
|
delete next[filterPanelColId];
|
|
388
400
|
handleFilterChange(next);
|
|
389
401
|
} else {
|
|
402
|
+
const entry: GridColumnFilters[string] = { value: filterPanelValue, op: filterPanelOp };
|
|
403
|
+
if ((filterPanelOp === "between" || filterPanelOp === "notBetween") && filterPanelValue2) {
|
|
404
|
+
entry.value2 = filterPanelValue2;
|
|
405
|
+
}
|
|
390
406
|
handleFilterChange({
|
|
391
407
|
...resolvedFilters,
|
|
392
|
-
[filterPanelColId]:
|
|
408
|
+
[filterPanelColId]: entry,
|
|
393
409
|
});
|
|
394
410
|
}
|
|
395
411
|
menu?.hide("gridler-filter-panel");
|
|
@@ -503,6 +519,7 @@
|
|
|
503
519
|
let serverData = $state<Record<string, unknown>[]>([]);
|
|
504
520
|
let serverTotal = $state(0);
|
|
505
521
|
let serverCursor = $state<string | undefined>(undefined);
|
|
522
|
+
let serverFetchVersion = $state(0);
|
|
506
523
|
let serverLoading = $state(false);
|
|
507
524
|
let serverAllLoaded = $state(false);
|
|
508
525
|
let serverError = $state<string | null>(null);
|
|
@@ -570,15 +587,16 @@
|
|
|
570
587
|
const _pageSize = pageSize;
|
|
571
588
|
|
|
572
589
|
// Track resolvedFilters so internal filter changes also trigger a re-fetch.
|
|
573
|
-
|
|
574
|
-
void resolvedFilters;
|
|
590
|
+
const _filters = resolvedFilters;
|
|
575
591
|
// Track manual refresh trigger.
|
|
576
|
-
|
|
577
|
-
refreshTrigger;
|
|
592
|
+
const _refresh = refreshTrigger;
|
|
578
593
|
|
|
594
|
+
// _refresh is tracked to trigger re-fetch on manual refresh.
|
|
595
|
+
void _refresh;
|
|
579
596
|
if (!_adapter) return;
|
|
580
597
|
|
|
581
598
|
let cancelled = false;
|
|
599
|
+
serverFetchVersion++;
|
|
582
600
|
serverLoading = true;
|
|
583
601
|
serverError = null;
|
|
584
602
|
serverData = [];
|
|
@@ -586,7 +604,13 @@
|
|
|
586
604
|
serverCursor = undefined;
|
|
587
605
|
serverAllLoaded = false;
|
|
588
606
|
|
|
589
|
-
const
|
|
607
|
+
const columnFilters = Object.keys(_filters).length
|
|
608
|
+
? gridColumnFiltersToFilterOptions(_filters)
|
|
609
|
+
: [];
|
|
610
|
+
const searchFilters = _search?.trim()
|
|
611
|
+
? buildSearchFilters(_search, columnsState, searchColumns)
|
|
612
|
+
: [];
|
|
613
|
+
const allFilters = [...columnFilters, ...searchFilters];
|
|
590
614
|
|
|
591
615
|
_adapter
|
|
592
616
|
.readPage(_pageSize, undefined, _sort, allFilters, resolvedFields)
|
|
@@ -596,7 +620,7 @@
|
|
|
596
620
|
serverTotal = result.total;
|
|
597
621
|
serverCursor = result.nextCursor;
|
|
598
622
|
serverAllLoaded = result.data.length < _pageSize || !result.nextCursor;
|
|
599
|
-
onGridEvent?.("load"
|
|
623
|
+
onGridEvent?.("load");
|
|
600
624
|
})
|
|
601
625
|
.catch((e) => {
|
|
602
626
|
if (cancelled) return;
|
|
@@ -619,7 +643,7 @@
|
|
|
619
643
|
if (!_adapter || serverLoading || serverAllLoaded || !serverCursor) return;
|
|
620
644
|
|
|
621
645
|
const cursorSnapshot = serverCursor;
|
|
622
|
-
const
|
|
646
|
+
const versionSnapshot = serverFetchVersion;
|
|
623
647
|
serverLoading = true;
|
|
624
648
|
|
|
625
649
|
const allFilters = buildAllFilters(
|
|
@@ -634,13 +658,14 @@
|
|
|
634
658
|
allFilters,
|
|
635
659
|
resolvedFields,
|
|
636
660
|
);
|
|
637
|
-
if (
|
|
661
|
+
if (serverFetchVersion !== versionSnapshot) return;
|
|
638
662
|
serverData = [...serverData, ...result.data];
|
|
639
663
|
serverCursor = result.nextCursor;
|
|
640
664
|
serverAllLoaded = result.data.length < pageSize || !result.nextCursor;
|
|
641
665
|
onGridEvent?.("page_loaded", undefined, undefined, undefined, { data: result.data, total: serverData.length });
|
|
642
|
-
} catch {
|
|
643
|
-
|
|
666
|
+
} catch (e) {
|
|
667
|
+
const msg = e instanceof Error ? e.message : "Failed to load page";
|
|
668
|
+
onLoadError?.(msg);
|
|
644
669
|
} finally {
|
|
645
670
|
serverLoading = false;
|
|
646
671
|
}
|
|
@@ -682,6 +707,11 @@
|
|
|
682
707
|
|
|
683
708
|
let internalSelectedItems = $state<Record<string, unknown>[]>([]);
|
|
684
709
|
|
|
710
|
+
$effect(() => {
|
|
711
|
+
if (_selectedItems === undefined) return;
|
|
712
|
+
internalSelectedItems = Array.isArray(_selectedItems) ? _selectedItems : [];
|
|
713
|
+
});
|
|
714
|
+
|
|
685
715
|
function handleSelectionChange(sel: Selection) {
|
|
686
716
|
let items: Record<string, unknown>[] = [];
|
|
687
717
|
if (sel.type === "row") {
|
|
@@ -706,7 +736,7 @@
|
|
|
706
736
|
|
|
707
737
|
// ── Settings change event ─────────────────────────────────────────────────
|
|
708
738
|
|
|
709
|
-
let lastSettings = $state(
|
|
739
|
+
let lastSettings = $state<typeof settings>(undefined);
|
|
710
740
|
$effect(() => {
|
|
711
741
|
if (settings === untrack(() => lastSettings)) return;
|
|
712
742
|
lastSettings = settings;
|
|
@@ -804,7 +834,6 @@
|
|
|
804
834
|
selectedItems={internalSelectedItems}
|
|
805
835
|
getRowData={resolveRowData}
|
|
806
836
|
onSelectionChange={handleSelectionChange}
|
|
807
|
-
{onMenuClick}
|
|
808
837
|
onGridMenuOpen={handleGridMenuClick}
|
|
809
838
|
sortOrder={resolvedSortOrder}
|
|
810
839
|
onSortOrderChange={handleSortOrderChange}
|
|
@@ -842,8 +871,10 @@
|
|
|
842
871
|
role="dialog"
|
|
843
872
|
aria-modal="true"
|
|
844
873
|
aria-labelledby="gf-filter-title"
|
|
874
|
+
tabindex="-1"
|
|
875
|
+
onkeydown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
845
876
|
>
|
|
846
|
-
<div class="gf-filter-title">{filterPanelColTitle}</div>
|
|
877
|
+
<div id="gf-filter-title" class="gf-filter-title">{filterPanelColTitle}</div>
|
|
847
878
|
<select bind:value={filterPanelOp} class="gf-filter-select">
|
|
848
879
|
<option value="contains">Contains</option>
|
|
849
880
|
<option value="equals">Equals</option>
|
|
@@ -854,6 +885,10 @@
|
|
|
854
885
|
<option value="lessThan">Less than</option>
|
|
855
886
|
<option value="greaterThanOrEqual">Greater than or equal</option>
|
|
856
887
|
<option value="lessThanOrEqual">Less than or equal</option>
|
|
888
|
+
<option value="in">In (comma-separated)</option>
|
|
889
|
+
<option value="notIn">Not in (comma-separated)</option>
|
|
890
|
+
<option value="between">Between</option>
|
|
891
|
+
<option value="notBetween">Not between</option>
|
|
857
892
|
<option value="isNull">Is empty / null</option>
|
|
858
893
|
<option value="isNotNull">Is not empty / null</option>
|
|
859
894
|
</select>
|
|
@@ -862,11 +897,20 @@
|
|
|
862
897
|
class="gf-filter-input"
|
|
863
898
|
type="text"
|
|
864
899
|
bind:value={filterPanelValue}
|
|
865
|
-
placeholder="Filter value…"
|
|
900
|
+
placeholder={filterPanelOp === "between" || filterPanelOp === "notBetween" ? "From value…" : "Filter value…"}
|
|
901
|
+
onkeydown={(e) => {
|
|
902
|
+
if (e.key === "Enter") applyFilterPanel();
|
|
903
|
+
}}
|
|
904
|
+
/>
|
|
905
|
+
{/if}
|
|
906
|
+
{#if filterPanelOp === "between" || filterPanelOp === "notBetween"}
|
|
907
|
+
<input
|
|
908
|
+
class="gf-filter-input"
|
|
909
|
+
type="text"
|
|
910
|
+
bind:value={filterPanelValue2}
|
|
911
|
+
placeholder="To value…"
|
|
866
912
|
onkeydown={(e) => {
|
|
867
|
-
if (e.key === "Enter")
|
|
868
|
-
applyFilterPanel();
|
|
869
|
-
}
|
|
913
|
+
if (e.key === "Enter") applyFilterPanel();
|
|
870
914
|
}}
|
|
871
915
|
/>
|
|
872
916
|
{/if}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
const IMAGE_CACHE_MAX = 500;
|
|
2
|
+
/** Loaded images keyed by URL. 'loading' prevents duplicate requests. Map insertion order acts as LRU. */
|
|
2
3
|
const imageCache = new Map();
|
|
3
4
|
/** Fired on document when a cached image finishes loading so the canvas repaints. */
|
|
4
5
|
export const IMAGE_LOADED_EVENT = 'gridler:image-loaded';
|
|
6
|
+
function touchCache(url, value) {
|
|
7
|
+
imageCache.delete(url);
|
|
8
|
+
imageCache.set(url, value);
|
|
9
|
+
if (imageCache.size > IMAGE_CACHE_MAX) {
|
|
10
|
+
const oldest = imageCache.keys().next().value;
|
|
11
|
+
if (oldest !== undefined)
|
|
12
|
+
imageCache.delete(oldest);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
5
15
|
/**
|
|
6
16
|
* Draws an image cell. Supported formats: PNG, JPG, SVG (anything <img> can load).
|
|
7
17
|
* cell.data (or cell.displayData) should be a URL or data URI.
|
|
@@ -35,16 +45,16 @@ export function drawImageCell(ctx, cell, x, y, size) {
|
|
|
35
45
|
// Placeholder while loading.
|
|
36
46
|
drawPlaceholder(ctx, x, y, size);
|
|
37
47
|
if (cached !== 'loading') {
|
|
38
|
-
|
|
48
|
+
touchCache(url, 'loading');
|
|
39
49
|
const img = new Image();
|
|
40
50
|
if (!isDataUri(url))
|
|
41
51
|
img.crossOrigin = 'anonymous';
|
|
42
52
|
img.onload = () => {
|
|
43
|
-
|
|
53
|
+
touchCache(url, img);
|
|
44
54
|
document.dispatchEvent(new CustomEvent(IMAGE_LOADED_EVENT));
|
|
45
55
|
};
|
|
46
56
|
img.onerror = () => {
|
|
47
|
-
|
|
57
|
+
touchCache(url, 'error');
|
|
48
58
|
document.dispatchEvent(new CustomEvent(IMAGE_LOADED_EVENT));
|
|
49
59
|
};
|
|
50
60
|
img.src = url;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strips
|
|
3
|
-
*
|
|
2
|
+
* Strips markdown syntax patterns and renders as plain text.
|
|
3
|
+
* Targets structural markdown (headings, bold, italic, code, links)
|
|
4
|
+
* while preserving those characters when they appear in normal content.
|
|
4
5
|
*/
|
|
5
6
|
export declare function drawMarkdownCell(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number): void;
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { truncateText } from './shared';
|
|
2
2
|
/**
|
|
3
|
-
* Strips
|
|
4
|
-
*
|
|
3
|
+
* Strips markdown syntax patterns and renders as plain text.
|
|
4
|
+
* Targets structural markdown (headings, bold, italic, code, links)
|
|
5
|
+
* while preserving those characters when they appear in normal content.
|
|
5
6
|
*/
|
|
6
7
|
export function drawMarkdownCell(ctx, text, x, y, maxWidth) {
|
|
7
|
-
const cleanText = text
|
|
8
|
+
const cleanText = text
|
|
9
|
+
.replace(/^#{1,6}\s+/gm, '') // headings: "## title" → "title"
|
|
10
|
+
.replace(/\*\*(.+?)\*\*/g, '$1') // bold: **text** → text
|
|
11
|
+
.replace(/__(.+?)__/g, '$1') // bold: __text__ → text
|
|
12
|
+
.replace(/\*(.+?)\*/g, '$1') // italic: *text* → text
|
|
13
|
+
.replace(/_(.+?)_/g, '$1') // italic: _text_ → text
|
|
14
|
+
.replace(/~~(.+?)~~/g, '$1') // strikethrough: ~~text~~ → text
|
|
15
|
+
.replace(/`(.+?)`/g, '$1') // inline code: `code` → code
|
|
16
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // links: [text](url) → text
|
|
8
17
|
const truncated = truncateText(ctx, cleanText, maxWidth);
|
|
9
18
|
ctx.fillText(truncated, x, y);
|
|
10
19
|
}
|
|
@@ -7,12 +7,17 @@ import { truncateText } from './shared';
|
|
|
7
7
|
export function drawPercentageCell(ctx, cell, x, y, maxWidth, fractional = true, decimals = 2, locale) {
|
|
8
8
|
let text;
|
|
9
9
|
if (typeof cell.data === 'number') {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
try {
|
|
11
|
+
const value = fractional ? cell.data : cell.data / 100;
|
|
12
|
+
text = new Intl.NumberFormat(locale, {
|
|
13
|
+
style: 'percent',
|
|
14
|
+
minimumFractionDigits: 0,
|
|
15
|
+
maximumFractionDigits: decimals,
|
|
16
|
+
}).format(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
text = cell.displayData;
|
|
20
|
+
}
|
|
16
21
|
}
|
|
17
22
|
else {
|
|
18
23
|
text = cell.displayData;
|
|
@@ -90,13 +90,16 @@ export function drawCell(ctx, x, y, width, height, cell, theme, isSelected, isHe
|
|
|
90
90
|
drawDrilldownCell(ctx, cell, textX, textY, maxWidth, theme);
|
|
91
91
|
break;
|
|
92
92
|
case 'currency':
|
|
93
|
-
drawCurrencyCell(ctx, cell, textX, textY, maxWidth);
|
|
93
|
+
drawCurrencyCell(ctx, cell, textX, textY, maxWidth, cell.formatOptions?.currency, cell.formatOptions?.locale);
|
|
94
94
|
break;
|
|
95
95
|
case 'percentage':
|
|
96
|
-
drawPercentageCell(ctx, cell, textX, textY, maxWidth);
|
|
96
|
+
drawPercentageCell(ctx, cell, textX, textY, maxWidth, cell.formatOptions?.fractional, cell.formatOptions?.decimals, cell.formatOptions?.locale);
|
|
97
97
|
break;
|
|
98
98
|
case 'datetime':
|
|
99
|
-
drawDatetimeCell(ctx, cell, textX, textY, maxWidth);
|
|
99
|
+
drawDatetimeCell(ctx, cell, textX, textY, maxWidth, cell.formatOptions?.dateStyle, cell.formatOptions?.timeStyle, cell.formatOptions?.locale);
|
|
100
|
+
break;
|
|
101
|
+
default:
|
|
102
|
+
drawTextCell(ctx, cell.displayData, textX, textY, maxWidth);
|
|
100
103
|
break;
|
|
101
104
|
}
|
|
102
105
|
ctx.restore();
|
|
@@ -111,7 +114,12 @@ export function drawHeaderCell(ctx, x, y, width, height, title, theme, isSelecte
|
|
|
111
114
|
ctx.fillRect(x, y, width, height);
|
|
112
115
|
ctx.strokeStyle = theme.borderColor;
|
|
113
116
|
ctx.lineWidth = 1;
|
|
114
|
-
ctx.
|
|
117
|
+
ctx.beginPath();
|
|
118
|
+
ctx.moveTo(x + width, y);
|
|
119
|
+
ctx.lineTo(x + width, y + height);
|
|
120
|
+
ctx.moveTo(x, y + height);
|
|
121
|
+
ctx.lineTo(x + width, y + height);
|
|
122
|
+
ctx.stroke();
|
|
115
123
|
if (isSelected) {
|
|
116
124
|
const dark = isDarkBg(theme.bgCell);
|
|
117
125
|
ctx.strokeStyle = theme.accentColor;
|
|
@@ -13,6 +13,11 @@ export declare function relativeLuminance({ r, g, b }: {
|
|
|
13
13
|
g: number;
|
|
14
14
|
b: number;
|
|
15
15
|
}): number;
|
|
16
|
+
export declare function parseRgb(color: string): {
|
|
17
|
+
r: number;
|
|
18
|
+
g: number;
|
|
19
|
+
b: number;
|
|
20
|
+
} | null;
|
|
16
21
|
export declare function isDarkBg(color: string): boolean;
|
|
17
22
|
/** Binary-search truncation — keeps the longest prefix that fits within maxWidth. */
|
|
18
23
|
export declare function truncateText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string;
|
|
@@ -26,25 +26,35 @@ export function relativeLuminance({ r, g, b }) {
|
|
|
26
26
|
const lin = srgb.map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
|
27
27
|
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
|
28
28
|
}
|
|
29
|
+
export function parseRgb(color) {
|
|
30
|
+
const match = color.match(/rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/);
|
|
31
|
+
if (!match)
|
|
32
|
+
return null;
|
|
33
|
+
return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]) };
|
|
34
|
+
}
|
|
29
35
|
export function isDarkBg(color) {
|
|
30
|
-
const rgb = hexToRgb(color);
|
|
36
|
+
const rgb = hexToRgb(color) ?? parseRgb(color);
|
|
31
37
|
if (!rgb)
|
|
32
38
|
return false;
|
|
33
39
|
return relativeLuminance(rgb) < 0.35;
|
|
34
40
|
}
|
|
35
41
|
/** Binary-search truncation — keeps the longest prefix that fits within maxWidth. */
|
|
36
42
|
export function truncateText(ctx, text, maxWidth) {
|
|
37
|
-
if (text.length === 0)
|
|
38
|
-
return
|
|
43
|
+
if (text.length === 0 || maxWidth <= 0)
|
|
44
|
+
return '';
|
|
39
45
|
const metrics = ctx.measureText(text);
|
|
40
46
|
if (metrics.width <= maxWidth)
|
|
41
47
|
return text;
|
|
48
|
+
// If even "..." doesn't fit, return empty.
|
|
49
|
+
const ellipsis = '...';
|
|
50
|
+
if (ctx.measureText(ellipsis).width > maxWidth)
|
|
51
|
+
return '';
|
|
42
52
|
let low = 0;
|
|
43
53
|
let high = text.length;
|
|
44
|
-
let result =
|
|
54
|
+
let result = ellipsis;
|
|
45
55
|
while (low <= high) {
|
|
46
56
|
const mid = Math.floor((low + high) / 2);
|
|
47
|
-
const truncated = text.slice(0, mid) +
|
|
57
|
+
const truncated = text.slice(0, mid) + ellipsis;
|
|
48
58
|
const width = ctx.measureText(truncated).width;
|
|
49
59
|
if (width <= maxWidth) {
|
|
50
60
|
result = truncated;
|
|
@@ -45,6 +45,20 @@ export interface GridlerColumn extends GridColumn<Record<string, unknown>> {
|
|
|
45
45
|
/** Show a context-menu trigger button on this column's header. */
|
|
46
46
|
hasMenu?: boolean;
|
|
47
47
|
}
|
|
48
|
+
export interface GridlerCellFormatOptions {
|
|
49
|
+
/** Currency code for 'currency' cells (default: 'ZAR'). */
|
|
50
|
+
currency?: string;
|
|
51
|
+
/** Locale for number/date formatting. */
|
|
52
|
+
locale?: string;
|
|
53
|
+
/** For 'percentage' cells: true if data is 0–1 fractional (default), false if 0–100. */
|
|
54
|
+
fractional?: boolean;
|
|
55
|
+
/** Decimal places for 'percentage' cells (default: 2). */
|
|
56
|
+
decimals?: number;
|
|
57
|
+
/** Date style for 'datetime' cells (default: 'short'). */
|
|
58
|
+
dateStyle?: Intl.DateTimeFormatOptions['dateStyle'];
|
|
59
|
+
/** Time style for 'datetime' cells. */
|
|
60
|
+
timeStyle?: Intl.DateTimeFormatOptions['timeStyle'];
|
|
61
|
+
}
|
|
48
62
|
export interface GridlerCell {
|
|
49
63
|
kind: GridlerCellKind;
|
|
50
64
|
data: unknown;
|
|
@@ -53,6 +67,8 @@ export interface GridlerCell {
|
|
|
53
67
|
allowEditing?: boolean;
|
|
54
68
|
readonly?: boolean;
|
|
55
69
|
span?: [startRow: number, endRow: number];
|
|
70
|
+
/** Per-cell format options for currency, percentage, and datetime renderers. */
|
|
71
|
+
formatOptions?: GridlerCellFormatOptions;
|
|
56
72
|
}
|
|
57
73
|
export type Selection = {
|
|
58
74
|
type: "none";
|