@arcforgelabs/flow 2026.10.44 → 2026.10.46
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 +1 -0
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +53 -3
- package/server/app.js +4 -2
- package/server/auth/people.js +4 -3
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/app.js +47 -27
- package/server/ui/columns.js +82 -15
- package/server/ui/flow.css +148 -37
- package/server/ui/invoicing.js +87 -38
- package/server/ui/job-view.js +33 -4
- package/server/ui/jobs.js +6 -37
- package/server/ui/kit.js +18 -11
- package/server/ui/list-views.js +44 -2
- package/server/ui/monitoring.js +51 -8
- package/server/ui/page.js +110 -5
- package/server/ui/pager.js +67 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +4 -4
- package/server/ui/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
package/server/ui/app.js
CHANGED
|
@@ -18,6 +18,7 @@ import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
|
18
18
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
19
19
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
20
20
|
import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
21
|
+
import { xeroUsage } from '/assets/ui/xero-usage.js';
|
|
21
22
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
22
23
|
import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
23
24
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
@@ -142,23 +143,26 @@ function roleSelect(value, attrs = {}) {
|
|
|
142
143
|
|
|
143
144
|
let me = null;
|
|
144
145
|
|
|
145
|
-
//
|
|
146
|
+
// Where each view sits: the sidebar, in this order, daily work first (no
|
|
147
|
+
// `place`); the sidebar's More menu (`place: 'more'`, `more` names its Lucide
|
|
148
|
+
// icon); the account menu (`place: 'account'`). `sidebar: false` views open
|
|
149
|
+
// by link and Search only.
|
|
146
150
|
const VIEWS = {
|
|
147
|
-
jobs: { label: 'Jobs', icon: 'jobs',
|
|
148
|
-
invoices: { label: 'Invoices', icon: 'invoices',
|
|
149
|
-
reconcile: { label: 'Reconcile', icon: 'reconcile',
|
|
150
|
-
'
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
'custom-fields': { label: 'Custom fields', icon: 'fields',
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
|
|
152
|
+
invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
153
|
+
reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
154
|
+
'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
155
|
+
'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
|
|
156
|
+
automations: { label: 'Automations', icon: 'bolt', place: 'more', more: 'zap', render: (main) => renderAutomations(main, ui()) },
|
|
157
|
+
'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', more: 'form-input', render: (main) => renderCustomFields(main, ui()) },
|
|
158
|
+
integrations: { label: 'Integrations', icon: 'plug', place: 'more', more: 'plug', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
159
|
+
people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
|
|
156
160
|
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
157
161
|
// Clerk profile. The page stays for links and Search.
|
|
158
162
|
// Opened from the Xero integration page: Xero invoices against Flow's own.
|
|
159
|
-
'xero-import': { label: 'Xero import', icon: 'plug',
|
|
160
|
-
account: { label: 'Account', icon: 'person',
|
|
161
|
-
appearance: { label: 'Appearance', icon: 'palette',
|
|
163
|
+
'xero-import': { label: 'Xero import', icon: 'plug', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
|
|
164
|
+
account: { label: 'Account', icon: 'person', sidebar: false, render: renderAccount },
|
|
165
|
+
appearance: { label: 'Appearance', icon: 'palette', place: 'account', render: renderAppearance },
|
|
162
166
|
};
|
|
163
167
|
|
|
164
168
|
// Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
|
|
@@ -176,6 +180,7 @@ const ICON_PATHS = {
|
|
|
176
180
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
177
181
|
search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
|
|
178
182
|
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
|
|
183
|
+
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
|
179
184
|
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
180
185
|
};
|
|
181
186
|
|
|
@@ -238,7 +243,7 @@ document.addEventListener('keydown', (event) => {
|
|
|
238
243
|
}
|
|
239
244
|
});
|
|
240
245
|
document.addEventListener('click', (event) => {
|
|
241
|
-
if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
|
|
246
|
+
if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]:not([data-nav="more"])')) closeMenu();
|
|
242
247
|
});
|
|
243
248
|
// Folded, a click on the sidebar's empty space opens it out again, as in
|
|
244
249
|
// Claude. A click on a page, the account photo or Search still does its job.
|
|
@@ -266,15 +271,19 @@ document.addEventListener('keydown', (event) => {
|
|
|
266
271
|
|
|
267
272
|
// "Report a problem" in Clerk's account menu, between "Manage account" and
|
|
268
273
|
// "Sign out". Clerk mounts and unmounts the icon itself.
|
|
269
|
-
function
|
|
274
|
+
function accountMenuItem(label, iconName, onClick) {
|
|
270
275
|
return {
|
|
271
|
-
label
|
|
272
|
-
onClick
|
|
273
|
-
mountIcon: (element) => element.replaceChildren(icon(
|
|
276
|
+
label,
|
|
277
|
+
onClick,
|
|
278
|
+
mountIcon: (element) => element.replaceChildren(icon(iconName)),
|
|
274
279
|
unmountIcon: (element) => element.replaceChildren(),
|
|
275
280
|
};
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
function reportMenuItem(openReport) {
|
|
284
|
+
return accountMenuItem('Report a problem', 'flag', () => { openReport().catch((error) => toast(error.message, 'danger')); });
|
|
285
|
+
}
|
|
286
|
+
|
|
278
287
|
// The page a person lands on after sign-in, and for / or an unknown path.
|
|
279
288
|
// Invoices opens on its Ready tab. A home page can take this over later.
|
|
280
289
|
const START_VIEW = 'invoices';
|
|
@@ -295,6 +304,8 @@ async function route() {
|
|
|
295
304
|
for (const link of document.querySelectorAll('a[data-nav]')) {
|
|
296
305
|
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
297
306
|
}
|
|
307
|
+
// More shows as current while one of its pages is open.
|
|
308
|
+
document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
|
|
298
309
|
main.replaceChildren();
|
|
299
310
|
main.className = 'fx-main';
|
|
300
311
|
document.title = `${VIEWS[view].label} · Flow`;
|
|
@@ -352,14 +363,17 @@ function renderShell() {
|
|
|
352
363
|
document.body.dataset.state = 'signed-in';
|
|
353
364
|
const nav = $('nav');
|
|
354
365
|
const items = [];
|
|
355
|
-
|
|
366
|
+
const more = [];
|
|
356
367
|
for (const [name, view] of Object.entries(VIEWS)) {
|
|
357
368
|
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
358
|
-
if (view.sidebar === false) continue;
|
|
359
|
-
if (view.
|
|
360
|
-
|
|
361
|
-
items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
369
|
+
if (view.sidebar === false || view.place === 'account') continue;
|
|
370
|
+
if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
|
|
371
|
+
else items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
362
372
|
}
|
|
373
|
+
// The rest of the setup pages, behind More (the shared ⋯ menu).
|
|
374
|
+
const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
|
|
375
|
+
Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
|
|
376
|
+
items.push(moreButton, morePanel);
|
|
363
377
|
// "Report a problem" sits at the foot of the menu, above the account chip,
|
|
364
378
|
// while Sentry and its report form are on (monitoring.js).
|
|
365
379
|
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
@@ -376,7 +390,11 @@ function renderShell() {
|
|
|
376
390
|
drawMenu();
|
|
377
391
|
// The sidebar foot shows who is signed in: photo or initial, then the name
|
|
378
392
|
// (the email when the account has no name). It opens the account menu.
|
|
379
|
-
|
|
393
|
+
// People (owners) and Appearance live in the account menu.
|
|
394
|
+
const accountItems = Object.entries(VIEWS)
|
|
395
|
+
.filter(([, view]) => view.place === 'account' && (!view.ownerOnly || me?.person.role === 'owner'))
|
|
396
|
+
.map(([name, view]) => accountMenuItem(view.label, view.icon, () => go(`/${name}`)));
|
|
397
|
+
const userButton = { afterSignOutUrl: '/sign-in', showName: true, customMenuItems: accountItems };
|
|
380
398
|
$('search').replaceChildren(icon('search'));
|
|
381
399
|
$('search').onclick = search;
|
|
382
400
|
mountClerk('userButton', $('user-button'), userButton);
|
|
@@ -389,7 +407,7 @@ function renderShell() {
|
|
|
389
407
|
report.hidden = false;
|
|
390
408
|
report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
|
|
391
409
|
unmountClerk($('user-button'));
|
|
392
|
-
mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
|
|
410
|
+
mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [...accountItems, reportMenuItem(openReport)] });
|
|
393
411
|
})
|
|
394
412
|
.catch((error) => console.warn(`[monitoring] ${error.message}`));
|
|
395
413
|
window.addEventListener('popstate', route);
|
|
@@ -873,10 +891,11 @@ function xeroImportSection(organisations) {
|
|
|
873
891
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
874
892
|
// Connect, which goes to Xero and comes back.
|
|
875
893
|
function xeroConnection(organisations, canChange) {
|
|
876
|
-
const host = h('div', { class: 'fx-
|
|
894
|
+
const host = h('div', { class: 'fx-xero-orgs' });
|
|
877
895
|
for (const organisation of organisations) {
|
|
896
|
+
const usage = h('div');
|
|
878
897
|
const row = settingRow({ label: organisation.name, hint: 'Checking…' });
|
|
879
|
-
host.append(row);
|
|
898
|
+
host.append(h('div', { class: 'fx-xero-org' }, usage, row));
|
|
880
899
|
(async () => {
|
|
881
900
|
let hint;
|
|
882
901
|
let control = null;
|
|
@@ -884,6 +903,7 @@ function xeroConnection(organisations, canChange) {
|
|
|
884
903
|
let refused = false;
|
|
885
904
|
try {
|
|
886
905
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
906
|
+
usage.replaceChildren(xeroUsage(status) ?? '');
|
|
887
907
|
if (status.connected && status.calls?.stopped) {
|
|
888
908
|
// A refused connection stopped calls (the call guard): say so, and
|
|
889
909
|
// make Connect again the main button, which starts them again.
|
package/server/ui/columns.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// A list's columns, as ClickUp's: drag a head's right edge to make the
|
|
2
2
|
// column wider or narrower (never under its kind's minimum), drag a head
|
|
3
3
|
// to move the column, and click or right-click a head for its menu (Move to
|
|
4
|
-
// start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices
|
|
5
|
-
// and
|
|
4
|
+
// start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices,
|
|
5
|
+
// Reconcile and the Price list build their tables here, so every list works
|
|
6
|
+
// the same.
|
|
6
7
|
//
|
|
7
8
|
// The view a person keeps for a list (with their account, PUT
|
|
8
9
|
// /api/me/views/:page) holds two keys from here:
|
|
@@ -24,12 +25,58 @@
|
|
|
24
25
|
// column keeps its place: no drag, no Move, no Hide.
|
|
25
26
|
// rows the <tbody> nodes (rowCells builds a row's cells)
|
|
26
27
|
// lead <th> nodes before the columns (the tick column)
|
|
28
|
+
//
|
|
29
|
+
// Cells every list shares: dateCell (a short date), personCell (a person's
|
|
30
|
+
// initials) and fieldColumn (one of the lab's custom fields as a column, on
|
|
31
|
+
// Jobs and on Invoices, where it is the invoice's job's value).
|
|
27
32
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
28
|
-
import { MAX_WIDTH, minWidth, moveColumn, widthOf } from '/assets/ui/job-view.js';
|
|
33
|
+
import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
|
|
34
|
+
import { initialsOf } from '/assets/ui/person.js';
|
|
35
|
+
|
|
36
|
+
const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
37
|
+
// A list date, short: Today, Tomorrow or Yesterday, else day/month (and
|
|
38
|
+
// year when it is not this year).
|
|
39
|
+
export function shortDate(iso) {
|
|
40
|
+
if (!iso) return '—';
|
|
41
|
+
const at = new Date(`${iso}T00:00:00`);
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
|
|
44
|
+
if (days === 0) return 'Today';
|
|
45
|
+
if (days === 1) return 'Tomorrow';
|
|
46
|
+
if (days === -1) return 'Yesterday';
|
|
47
|
+
return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A date cell: the short date, the whole date on hover.
|
|
51
|
+
export const dateCell = (h, iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
|
|
52
|
+
|
|
53
|
+
// A person as a badge (initials), the name on hover; `people` is
|
|
54
|
+
// [{ id, name }] (GET /api/custom-fields).
|
|
55
|
+
export function personCell(h, personId, people) {
|
|
56
|
+
if (!personId) return h('span', { class: 'fx-dim' }, '—');
|
|
57
|
+
const name = people.find((p) => p.id === personId)?.name ?? 'Former person';
|
|
58
|
+
return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A custom field's value as text.
|
|
62
|
+
export function fieldText(field, value, people) {
|
|
63
|
+
if (value == null || value === false) return '—';
|
|
64
|
+
if (field.type === 'checkbox') return 'Yes';
|
|
65
|
+
if (field.type === 'person') return people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
66
|
+
if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
|
|
67
|
+
if (field.type === 'date') return shortDate(value);
|
|
68
|
+
return String(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One custom field as a list column: `valueOf(row)` is the row's value
|
|
72
|
+
// (null when it has none), `people` names a person field's value.
|
|
73
|
+
export function fieldColumn(h, field, { id, valueOf, people }) {
|
|
74
|
+
const cell = field.type === 'person' ? (row) => personCell(h, valueOf(row), people)
|
|
75
|
+
: field.type === 'date' ? (row) => dateCell(h, valueOf(row)) : (row) => fieldText(field, valueOf(row), people);
|
|
76
|
+
const kind = field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text';
|
|
77
|
+
return { id, label: field.name, kind, num: field.type === 'number', className: 'fx-dim', cell };
|
|
78
|
+
}
|
|
29
79
|
|
|
30
|
-
// The tick column at the start and the + column at the end.
|
|
31
|
-
const LEAD_WIDTH = 44;
|
|
32
|
-
const TAIL_WIDTH = 40;
|
|
33
80
|
|
|
34
81
|
// A row's cells for the shown columns: a class per kind, the label for
|
|
35
82
|
// the phone layout, the full text as the cell's name on hover.
|
|
@@ -110,23 +157,42 @@ export function columnTable(h, config) {
|
|
|
110
157
|
|
|
111
158
|
function table({ columns, rows, lead = [], className = '' }) {
|
|
112
159
|
const saved = widths();
|
|
113
|
-
// Each column keeps its own width:
|
|
114
|
-
//
|
|
115
|
-
//
|
|
160
|
+
// Each column keeps its own width: never stretched to the page. As
|
|
161
|
+
// HubSpot's, the head and every row still run to the right edge: the
|
|
162
|
+
// end column (+ at its right) takes the space after the last column, so
|
|
163
|
+
// a row looks whole, not cut off. A resize changes the table only; the
|
|
164
|
+
// tools stay put.
|
|
116
165
|
const cols = columns.map((c) => h('col', { style: { width: `${widthOf(c, saved)}px` } }));
|
|
117
166
|
const leadCols = lead.map(() => h('col', { style: { width: `${LEAD_WIDTH}px` } }));
|
|
167
|
+
const endCol = h('col', { style: { width: `${TAIL_WIDTH}px` } });
|
|
118
168
|
const tail = config.hidden ? h('th', { class: 'fx-th-add' }, h('button', {
|
|
119
169
|
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => openMenu(null, event),
|
|
120
170
|
}, '+')) : h('th', { class: 'fx-th-end', 'aria-hidden': 'true' });
|
|
121
171
|
const heads = columns.map((c, n) => head(c, n));
|
|
122
172
|
const node = h('table', { class: `fx-table fx-table--sized ${className}`.trim() },
|
|
123
|
-
h('colgroup', {}, leadCols, cols,
|
|
173
|
+
h('colgroup', {}, leadCols, cols, endCol),
|
|
124
174
|
h('thead', {}, h('tr', {}, lead, heads, tail)),
|
|
125
175
|
rows);
|
|
126
|
-
|
|
127
|
-
|
|
176
|
+
const box = h('div', { class: 'fx-table-scroll' }, node);
|
|
177
|
+
// The width the columns need; past the window the table scrolls
|
|
178
|
+
// sideways. A browser shares spare width among fixed columns, so the
|
|
179
|
+
// end column is sized here, and again when the window changes size.
|
|
180
|
+
let need = tableWidth(columns, saved, lead.length);
|
|
181
|
+
const fit = () => {
|
|
182
|
+
const end = endWidth(need, box.getBoundingClientRect?.().width ?? 0);
|
|
183
|
+
endCol.style.width = `${end}px`;
|
|
184
|
+
node.style.width = `${need - TAIL_WIDTH + end}px`;
|
|
185
|
+
};
|
|
128
186
|
fit();
|
|
129
|
-
|
|
187
|
+
if (typeof ResizeObserver === 'function') {
|
|
188
|
+
// Once the table has left the page (a redraw), stop watching.
|
|
189
|
+
let shown = false;
|
|
190
|
+
const watch = new ResizeObserver(() => {
|
|
191
|
+
if (box.isConnected) { shown = true; fit(); } else if (shown) watch.disconnect();
|
|
192
|
+
});
|
|
193
|
+
watch.observe(box);
|
|
194
|
+
}
|
|
195
|
+
return box;
|
|
130
196
|
|
|
131
197
|
function head(c, n) {
|
|
132
198
|
const button = h('button', {
|
|
@@ -167,12 +233,13 @@ export function columnTable(h, config) {
|
|
|
167
233
|
grip.setPointerCapture(event.pointerId);
|
|
168
234
|
const from = event.clientX;
|
|
169
235
|
const start = col.getBoundingClientRect().width || widthOf(c, widths());
|
|
170
|
-
const base =
|
|
236
|
+
const base = need;
|
|
171
237
|
let w = start;
|
|
172
238
|
node.dataset.resizing = '';
|
|
173
239
|
const move = (e) => {
|
|
174
240
|
w = set(start + e.clientX - from);
|
|
175
|
-
|
|
241
|
+
need = base + w - start;
|
|
242
|
+
fit();
|
|
176
243
|
};
|
|
177
244
|
const up = () => {
|
|
178
245
|
grip.removeEventListener('pointermove', move);
|