wenay-react2 1.0.33 → 1.0.34
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/lib/common/api.d.ts +1 -0
- package/lib/common/api.js +3 -0
- package/lib/common/src/components/Toolbar/Toolbar.d.ts +105 -0
- package/lib/common/src/components/Toolbar/Toolbar.js +210 -0
- package/lib/common/src/components/Toolbar/index.d.ts +1 -0
- package/lib/common/src/components/Toolbar/index.js +1 -0
- package/lib/common/src/hooks/index.d.ts +2 -0
- package/lib/common/src/hooks/index.js +2 -0
- package/lib/common/src/hooks/useReorder.d.ts +49 -0
- package/lib/common/src/hooks/useReorder.js +134 -0
- package/lib/common/src/hooks/useReorderBoard.d.ts +61 -0
- package/lib/common/src/hooks/useReorderBoard.js +232 -0
- package/lib/common/src/hooks/useReplay.d.ts +63 -0
- package/lib/common/src/hooks/useReplay.js +128 -8
- package/lib/common/src/styles/tokens.d.ts +17 -0
- package/lib/common/src/styles/tokens.js +17 -0
- package/lib/style/style.css +128 -0
- package/lib/style/tokens.css +14 -0
- package/package.json +2 -2
package/lib/common/api.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export * from "./src/components/Modal";
|
|
|
27
27
|
export * from "./src/components/Menu";
|
|
28
28
|
export * from "./src/components/Settings";
|
|
29
29
|
export * from "./src/components/UiSlot";
|
|
30
|
+
export * from "./src/components/Toolbar";
|
|
30
31
|
export { MenuBase, TimeNum, MenuElement as LegacyMenuElement } from "./src/menu/menu";
|
|
31
32
|
export type { tMenuReact, tMenuReactStrictly } from "./src/menu/menu";
|
|
32
33
|
export * from "./src/menu/menuMouse";
|
package/lib/common/api.js
CHANGED
|
@@ -40,6 +40,9 @@ export * from "./src/components/Menu";
|
|
|
40
40
|
// 11b. Settings dialog + section registry; UI slot with configurable placement
|
|
41
41
|
export * from "./src/components/Settings";
|
|
42
42
|
export * from "./src/components/UiSlot";
|
|
43
|
+
// 11c. Customizable toolbar (createToolbar): config persisted like createUiSlot,
|
|
44
|
+
// pure Settings editor works both in the bar's gear popover and in a settings section
|
|
45
|
+
export * from "./src/components/Toolbar";
|
|
43
46
|
// 12. MENU - depends on components
|
|
44
47
|
export { MenuBase, TimeNum, MenuElement as LegacyMenuElement } from "./src/menu/menu";
|
|
45
48
|
export * from "./src/menu/menuMouse";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/** createToolbar - a customizable, self-describing toolbar primitive.
|
|
3
|
+
* Three decoupled layers: config (plain serializable data, persisted via
|
|
4
|
+
* staticProps -> Cash, same mechanics as createUiSlot), Bar (renders visible
|
|
5
|
+
* items in config order at config density) and Settings (a pure editor that
|
|
6
|
+
* only reads/writes config) - so the SAME Settings element works both in the
|
|
7
|
+
* Bar's own gear popover and in a global settings section.
|
|
8
|
+
* v1 non-goals: no overflow/"more" menu (an overflow hook would live in Bar,
|
|
9
|
+
* right after the visible-items map), no grouping, no cross-bar drag. */
|
|
10
|
+
export type tToolbarItem = {
|
|
11
|
+
/** stable id (persist key) */
|
|
12
|
+
key: string;
|
|
13
|
+
/** full human name - shown in the Settings list */
|
|
14
|
+
title: string;
|
|
15
|
+
/** short caption for 'label' density (falls back to title) */
|
|
16
|
+
short?: string;
|
|
17
|
+
/** compact glyph/icon for 'icon' density */
|
|
18
|
+
icon: React.ReactNode;
|
|
19
|
+
/** full custom render; default = icon [+ short] by the density registry */
|
|
20
|
+
render?: (density: string) => React.ReactNode;
|
|
21
|
+
/** convenience for plain action buttons; interactive items can also handle clicks inside render() */
|
|
22
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
23
|
+
/** default true */
|
|
24
|
+
defaultVisible?: boolean;
|
|
25
|
+
/** cannot be hidden or reordered (pinned to its index in opts.items) */
|
|
26
|
+
fixed?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type tToolbarConfig = {
|
|
29
|
+
/** item keys, display order */
|
|
30
|
+
order: string[];
|
|
31
|
+
visible: {
|
|
32
|
+
[key: string]: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** a key from the density registry ('icon' | 'label' | registered extras) */
|
|
35
|
+
density: string;
|
|
36
|
+
};
|
|
37
|
+
export type tToolbarDensity = {
|
|
38
|
+
key: string;
|
|
39
|
+
/** human name for the Settings segmented control */
|
|
40
|
+
name: string;
|
|
41
|
+
/** how one item renders at this density; absent = icon + (short ?? title) */
|
|
42
|
+
renderItem?: (item: tToolbarItem) => React.ReactNode;
|
|
43
|
+
};
|
|
44
|
+
/** Register an extra density level. Re-register with the same key replaces the
|
|
45
|
+
* previous one. The returned function removes exactly this registration. */
|
|
46
|
+
export declare function registerToolbarDensity(d: tToolbarDensity): () => void;
|
|
47
|
+
/** Current density registry (built-ins + registered). */
|
|
48
|
+
export declare function getToolbarDensities(): readonly tToolbarDensity[];
|
|
49
|
+
export declare function createToolbar(opts: {
|
|
50
|
+
/** persistence key (staticProps -> Cash), like createUiSlot */
|
|
51
|
+
key: string;
|
|
52
|
+
/** item descriptors; the config only ever references them by key */
|
|
53
|
+
items: tToolbarItem[];
|
|
54
|
+
/** defaults; missing fields are derived from items */
|
|
55
|
+
def?: Partial<tToolbarConfig>;
|
|
56
|
+
/** the gear button's face: icon/title default to a plain gear svg. The gear
|
|
57
|
+
* renders only when Bar mounts with settings, AND visible['__settings'] is
|
|
58
|
+
* not false - the Settings editor shows a separated toggle row for it. */
|
|
59
|
+
settingsItem?: {
|
|
60
|
+
title?: string;
|
|
61
|
+
icon?: React.ReactNode;
|
|
62
|
+
};
|
|
63
|
+
}): {
|
|
64
|
+
Bar: (p?: {
|
|
65
|
+
className?: string;
|
|
66
|
+
settings?: boolean;
|
|
67
|
+
popAlign?: "left" | "right";
|
|
68
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
69
|
+
Settings: (p?: {
|
|
70
|
+
className?: string;
|
|
71
|
+
activeClassName?: string;
|
|
72
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
73
|
+
api: {
|
|
74
|
+
useConfig: () => tToolbarConfig;
|
|
75
|
+
useItems: () => {
|
|
76
|
+
item: tToolbarItem;
|
|
77
|
+
density: string;
|
|
78
|
+
content: string | number | bigint | boolean | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | import("react/jsx-runtime").JSX.Element | null | undefined;
|
|
79
|
+
}[];
|
|
80
|
+
getConfig: () => tToolbarConfig;
|
|
81
|
+
setConfig: (next: tToolbarConfig) => void;
|
|
82
|
+
reset: () => void;
|
|
83
|
+
onChange: {
|
|
84
|
+
func: import("wenay-common2").Listener<[tToolbarConfig]>;
|
|
85
|
+
isRun: () => boolean;
|
|
86
|
+
run: () => void;
|
|
87
|
+
close: () => void;
|
|
88
|
+
eventClose: (cb: () => void) => () => void;
|
|
89
|
+
onClose: (cb: () => void) => () => void;
|
|
90
|
+
removeEventClose: (cb: () => void) => void;
|
|
91
|
+
on: import("wenay-common2").ListenOn<[tToolbarConfig]>;
|
|
92
|
+
off: (k: import("wenay-common2").Listener<[tToolbarConfig]> | (string | symbol) | null) => void;
|
|
93
|
+
addListen: (cb: import("wenay-common2").Listener<[tToolbarConfig]>, opts?: {
|
|
94
|
+
cbClose?: () => void;
|
|
95
|
+
key?: string | symbol;
|
|
96
|
+
}) => () => void;
|
|
97
|
+
removeListen: (k: import("wenay-common2").Listener<[tToolbarConfig]> | (string | symbol) | null) => void;
|
|
98
|
+
once: (cb: import("wenay-common2").Listener<[tToolbarConfig]>, opts?: {
|
|
99
|
+
key?: string | symbol;
|
|
100
|
+
}) => () => void;
|
|
101
|
+
count: () => number;
|
|
102
|
+
readonly getAllKeys: (string | symbol)[];
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { UseListen } from 'wenay-common2';
|
|
4
|
+
import { renderBy, updateBy } from '../../../updateBy';
|
|
5
|
+
import { staticGetAdd, staticMarkDirty } from '../../utils/mapMemory';
|
|
6
|
+
import { DivOutsideClick } from '../../hooks/useOutside';
|
|
7
|
+
import { useReorder } from '../../hooks/useReorder';
|
|
8
|
+
// Module singleton (closure + updateBy subscription, no React context) - same
|
|
9
|
+
// pattern as the settings-section registry. Two built-in levels; a third
|
|
10
|
+
// ("full" text etc.) is just one more registration.
|
|
11
|
+
const densities = { list: [
|
|
12
|
+
{ key: 'icon', name: 'Icons', renderItem: itemIconOnly },
|
|
13
|
+
{ key: 'label', name: 'Icons + labels' },
|
|
14
|
+
] };
|
|
15
|
+
function itemIconOnly(item) {
|
|
16
|
+
return item.icon;
|
|
17
|
+
}
|
|
18
|
+
/** Register an extra density level. Re-register with the same key replaces the
|
|
19
|
+
* previous one. The returned function removes exactly this registration. */
|
|
20
|
+
export function registerToolbarDensity(d) {
|
|
21
|
+
const i = densities.list.findIndex(e => e.key == d.key);
|
|
22
|
+
if (i == -1)
|
|
23
|
+
densities.list.push(d);
|
|
24
|
+
else
|
|
25
|
+
densities.list.splice(i, 1, d);
|
|
26
|
+
renderBy(densities);
|
|
27
|
+
return function offToolbarDensity() {
|
|
28
|
+
const j = densities.list.indexOf(d);
|
|
29
|
+
if (j != -1) {
|
|
30
|
+
densities.list.splice(j, 1);
|
|
31
|
+
renderBy(densities);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Current density registry (built-ins + registered). */
|
|
36
|
+
export function getToolbarDensities() {
|
|
37
|
+
return densities.list;
|
|
38
|
+
}
|
|
39
|
+
function itemContent(item, densityKey) {
|
|
40
|
+
if (item.render)
|
|
41
|
+
return item.render(densityKey);
|
|
42
|
+
const d = densities.list.find(e => e.key == densityKey);
|
|
43
|
+
if (d?.renderItem)
|
|
44
|
+
return d.renderItem(item);
|
|
45
|
+
return _jsxs(_Fragment, { children: [_jsx("span", { className: 'wenayTbIcon', children: item.icon }), _jsx("span", { className: 'wenayTbLabel', children: item.short ?? item.title })] });
|
|
46
|
+
}
|
|
47
|
+
/** Reserved visible-map key for the bar's settings (gear) button: not part of
|
|
48
|
+
* order (the gear always sits at the bar edge), but toggleable like an item. */
|
|
49
|
+
const SETTINGS_KEY = '__settings';
|
|
50
|
+
export function createToolbar(opts) {
|
|
51
|
+
const defConfig = () => ({
|
|
52
|
+
order: opts.def?.order?.slice() ?? opts.items.map(i => i.key),
|
|
53
|
+
visible: opts.def?.visible ? { ...opts.def.visible } : Object.fromEntries(opts.items.map(i => [i.key, i.defaultVisible != false])),
|
|
54
|
+
density: opts.def?.density ?? densities.list[0].key,
|
|
55
|
+
});
|
|
56
|
+
const st = staticGetAdd(opts.key, defConfig());
|
|
57
|
+
const [emitChange, onChange] = UseListen();
|
|
58
|
+
/** The persisted state may be stale or partial (older app version, removed
|
|
59
|
+
* items, an unregistered density) - never crash, never drop user data that
|
|
60
|
+
* still applies: unknown keys are filtered out, missing items are appended
|
|
61
|
+
* (default-visible), fixed items are pinned back to their descriptor index. */
|
|
62
|
+
function normalize() {
|
|
63
|
+
const known = new Set(opts.items.map(i => i.key));
|
|
64
|
+
const rawOrder = Array.isArray(st.order) ? st.order : [];
|
|
65
|
+
const order = rawOrder.filter(k => known.has(k) && !opts.items.find(i => i.key == k)?.fixed);
|
|
66
|
+
for (const it of opts.items)
|
|
67
|
+
if (!it.fixed && order.indexOf(it.key) == -1)
|
|
68
|
+
order.push(it.key);
|
|
69
|
+
opts.items.forEach(function pinFixed(it, i) {
|
|
70
|
+
if (it.fixed)
|
|
71
|
+
order.splice(Math.min(i, order.length), 0, it.key);
|
|
72
|
+
});
|
|
73
|
+
const rawVisible = st.visible && typeof st.visible == 'object' ? st.visible : {};
|
|
74
|
+
const visible = {};
|
|
75
|
+
for (const it of opts.items)
|
|
76
|
+
visible[it.key] = it.fixed ? true : (rawVisible[it.key] ?? it.defaultVisible != false);
|
|
77
|
+
visible[SETTINGS_KEY] = typeof rawVisible[SETTINGS_KEY] == 'boolean' ? rawVisible[SETTINGS_KEY] : true;
|
|
78
|
+
const density = typeof st.density == 'string' && densities.list.some(d => d.key == st.density)
|
|
79
|
+
? st.density : (opts.def?.density ?? densities.list[0].key);
|
|
80
|
+
return { order, visible, density };
|
|
81
|
+
}
|
|
82
|
+
/** Every edit funnels through here: mutate the persisted object in place
|
|
83
|
+
* (identity is the updateBy/renderBy subscription key), announce, mark the
|
|
84
|
+
* cache dirty, emit outward. */
|
|
85
|
+
function setConfig(next) {
|
|
86
|
+
st.order = next.order.slice();
|
|
87
|
+
st.visible = { ...next.visible };
|
|
88
|
+
st.density = next.density;
|
|
89
|
+
renderBy(st);
|
|
90
|
+
staticMarkDirty(opts.key);
|
|
91
|
+
emitChange(normalize());
|
|
92
|
+
}
|
|
93
|
+
const getConfig = () => normalize();
|
|
94
|
+
function useConfig() {
|
|
95
|
+
updateBy(st);
|
|
96
|
+
return normalize();
|
|
97
|
+
}
|
|
98
|
+
/** Headless bar: the ordered, visibility-filtered items plus their rendered
|
|
99
|
+
* content at the current density - build fully custom bar markup on top,
|
|
100
|
+
* while the same Settings editor still drives order/visibility/density.
|
|
101
|
+
* (Refs are the wrong contract here: the ORDER lives in the config, so the
|
|
102
|
+
* consumer re-renders from this list rather than the library re-parenting
|
|
103
|
+
* someone else's nodes.) */
|
|
104
|
+
function useItems() {
|
|
105
|
+
updateBy(st);
|
|
106
|
+
updateBy(densities);
|
|
107
|
+
const cfg = normalize();
|
|
108
|
+
const byKey = new Map(opts.items.map(i => [i.key, i]));
|
|
109
|
+
return cfg.order
|
|
110
|
+
.map(k => byKey.get(k))
|
|
111
|
+
.filter((it) => !!it && cfg.visible[it.key] != false)
|
|
112
|
+
.map(it => ({ item: it, density: cfg.density, content: itemContent(it, cfg.density) }));
|
|
113
|
+
}
|
|
114
|
+
const reset = () => setConfig(defConfig());
|
|
115
|
+
const settingsTitle = () => opts.settingsItem?.title ?? 'Toolbar settings';
|
|
116
|
+
const settingsIcon = () => opts.settingsItem?.icon ??
|
|
117
|
+
_jsx("svg", { width: '14', height: '14', viewBox: '0 0 16 16', "aria-hidden": 'true', children: _jsx("path", { d: 'M8 5.2 A2.8 2.8 0 1 0 8 10.8 A2.8 2.8 0 1 0 8 5.2 M8 1.2 L8.6 3.4 A4.8 4.8 0 0 1 10.6 4.2 L12.8 3.2 L14 5.6 L12.2 7 A4.8 4.8 0 0 1 12.2 9 L14 10.4 L12.8 12.8 L10.6 11.8 A4.8 4.8 0 0 1 8.6 12.6 L8 14.8 L7.4 12.6 A4.8 4.8 0 0 1 5.4 11.8 L3.2 12.8 L2 10.4 L3.8 9 A4.8 4.8 0 0 1 3.8 7 L2 5.6 L3.2 3.2 L5.4 4.2 A4.8 4.8 0 0 1 7.4 3.4 Z', fill: 'none', stroke: 'currentColor', strokeWidth: '1.2', strokeLinejoin: 'round' }) });
|
|
118
|
+
function moveKey(cfg, key, to) {
|
|
119
|
+
const order = cfg.order.slice();
|
|
120
|
+
const from = order.indexOf(key);
|
|
121
|
+
if (from == -1)
|
|
122
|
+
return;
|
|
123
|
+
to = Math.max(0, Math.min(order.length - 1, to));
|
|
124
|
+
if (to == from)
|
|
125
|
+
return;
|
|
126
|
+
order.splice(from, 1);
|
|
127
|
+
order.splice(to, 0, key);
|
|
128
|
+
setConfig({ ...cfg, order });
|
|
129
|
+
}
|
|
130
|
+
/** The live bar. settings=true adds a built-in gear opening Settings in a
|
|
131
|
+
* local popover - optional, some consumers mount Settings only in the
|
|
132
|
+
* global settings menu. popAlign: which bar edge the popover sticks to -
|
|
133
|
+
* 'right' (default, for bars in a top-right corner) or 'left'. */
|
|
134
|
+
function Bar(p = {}) {
|
|
135
|
+
updateBy(st);
|
|
136
|
+
updateBy(densities);
|
|
137
|
+
const cfg = normalize();
|
|
138
|
+
const [open, setOpen] = useState(false);
|
|
139
|
+
const byKey = new Map(opts.items.map(i => [i.key, i]));
|
|
140
|
+
return _jsxs("div", { className: p.className ?? 'wenayTb', children: [cfg.order.map(k => {
|
|
141
|
+
const it = byKey.get(k);
|
|
142
|
+
if (!it || cfg.visible[k] == false)
|
|
143
|
+
return null;
|
|
144
|
+
return _jsx("div", { className: 'wenayTbItem', title: cfg.density == 'icon' ? it.title : undefined, onClick: it.onClick, children: itemContent(it, cfg.density) }, k);
|
|
145
|
+
}), p.settings && cfg.visible[SETTINGS_KEY] != false && _jsxs(DivOutsideClick, { className: 'wenayTbGear', status: open, outsideClick: () => setOpen(false), children: [_jsx("div", { className: 'wenayTbItem', title: settingsTitle(), onClick: () => setOpen(v => !v), children: settingsIcon() }), open && _jsx("div", { className: p.popAlign == 'left' ? 'wenayTbPop wenayTbPopLeft' : 'wenayTbPop', children: _jsx(Settings, {}) })] })] });
|
|
146
|
+
}
|
|
147
|
+
/** The pure editor over config: density segments + one row per item
|
|
148
|
+
* (visibility checkbox, icon preview, title, drag handle). Reorder rides the
|
|
149
|
+
* library's useReorder (this editor is its first consumer): the whole row
|
|
150
|
+
* drags (mouse and touch; the checkbox is excluded), and `move` is the SAME
|
|
151
|
+
* simulated commit as normalize() (splice + fixed pinning), so fixed rows
|
|
152
|
+
* never move in the preview and a drop never lands elsewhere than shown.
|
|
153
|
+
* Plus arrow keys on the focused handle; fixed rows have neither. */
|
|
154
|
+
function Settings(p = {}) {
|
|
155
|
+
updateBy(st);
|
|
156
|
+
updateBy(densities);
|
|
157
|
+
const cfg = normalize();
|
|
158
|
+
const base = p.className ?? 'wenaySegBtn';
|
|
159
|
+
const activeCls = p.activeClassName ?? 'wenaySegBtnActive';
|
|
160
|
+
const byKey = new Map(opts.items.map(i => [i.key, i]));
|
|
161
|
+
/** Simulated commit: splice + the same fixed pinning as normalize(). */
|
|
162
|
+
function movedOrder(order, key, to) {
|
|
163
|
+
const next = order.slice();
|
|
164
|
+
const from = next.indexOf(key);
|
|
165
|
+
if (from == -1)
|
|
166
|
+
return next;
|
|
167
|
+
next.splice(from, 1);
|
|
168
|
+
next.splice(Math.max(0, Math.min(next.length, to)), 0, key);
|
|
169
|
+
const res = next.filter(k => !byKey.get(k)?.fixed);
|
|
170
|
+
opts.items.forEach(function pinFixed(it, i) {
|
|
171
|
+
if (it.fixed)
|
|
172
|
+
res.splice(Math.min(i, res.length), 0, it.key);
|
|
173
|
+
});
|
|
174
|
+
return res;
|
|
175
|
+
}
|
|
176
|
+
const reorder = useReorder({
|
|
177
|
+
order: cfg.order,
|
|
178
|
+
commit: order => setConfig({ ...cfg, order }),
|
|
179
|
+
move: movedOrder,
|
|
180
|
+
canDrag: k => !byKey.get(k)?.fixed,
|
|
181
|
+
});
|
|
182
|
+
function onHandleKey(key, e) {
|
|
183
|
+
const step = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];
|
|
184
|
+
if (!step)
|
|
185
|
+
return;
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
moveKey(cfg, key, cfg.order.indexOf(key) + step);
|
|
188
|
+
}
|
|
189
|
+
return _jsxs("div", { className: 'wenayTbSettings', children: [_jsx("div", { className: 'wenayTbDensity', children: densities.list.map(d => (_jsx("div", { className: d.key == cfg.density ? `${base} ${activeCls}` : base, onClick: () => setConfig({ ...cfg, density: d.key }), children: d.name }, d.key))) }), _jsx("div", { className: 'wenayTbRows', ref: reorder.listRef, children: cfg.order.map(k => {
|
|
190
|
+
const it = byKey.get(k);
|
|
191
|
+
if (!it)
|
|
192
|
+
return null;
|
|
193
|
+
const r = reorder.item(k);
|
|
194
|
+
let cls = it.fixed ? 'wenayTbRow' : 'wenayTbRow wenayTbRowGrab';
|
|
195
|
+
if (r.active)
|
|
196
|
+
cls += r.dragging ? ' wenayTbRowDrag' : ' wenayTbRowShift'; // Shift is transitioned, so displaced rows glide
|
|
197
|
+
return _jsxs("div", { className: cls, style: r.style, onMouseDown: it.fixed ? undefined : e => {
|
|
198
|
+
// the drag hook preventDefaults mousedown, which suppresses native focus
|
|
199
|
+
if (!e.target.closest('input'))
|
|
200
|
+
e.currentTarget.querySelector('.wenayTbHandle')?.focus();
|
|
201
|
+
r.props.onMouseDown(e);
|
|
202
|
+
}, onTouchStart: it.fixed ? undefined : r.props.onTouchStart, children: [_jsx("input", { type: 'checkbox', disabled: it.fixed, checked: cfg.visible[k] != false, onChange: () => setConfig({ ...cfg, visible: { ...cfg.visible, [k]: !(cfg.visible[k] != false) } }) }), _jsx("span", { className: 'wenayTbIcon', children: it.icon }), _jsx("span", { className: 'wenayTbRowTitle', children: it.title }), !it.fixed && _jsx("div", { className: 'wenayTbHandle', tabIndex: 0, title: 'Drag or arrow keys to reorder', onKeyDown: e => onHandleKey(k, e), children: "\u283F" })] }, k);
|
|
203
|
+
}) }), _jsxs("div", { className: 'wenayTbRow wenayTbRowMeta', children: [_jsx("input", { type: 'checkbox', checked: cfg.visible[SETTINGS_KEY] != false, onChange: () => setConfig({ ...cfg, visible: { ...cfg.visible, [SETTINGS_KEY]: !(cfg.visible[SETTINGS_KEY] != false) } }) }), _jsx("span", { className: 'wenayTbIcon', children: settingsIcon() }), _jsx("span", { className: 'wenayTbRowTitle', children: settingsTitle() })] })] });
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
Bar,
|
|
207
|
+
Settings,
|
|
208
|
+
api: { useConfig, useItems, getConfig, setConfig, reset, onChange },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Toolbar';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Toolbar';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/** useReorder - a deliberately small reorder-by-drag for keyed blocks laid out
|
|
3
|
+
* by CSS (vertical list, horizontal bar, wrapped grid - the hook never knows
|
|
4
|
+
* which). The DOM order does NOT change mid-drag: the dragged block follows
|
|
5
|
+
* the pointer via transform, the rest glide to their preview position, ONE
|
|
6
|
+
* commit fires on drop. The consumer renders children in `order`, 1:1 with
|
|
7
|
+
* the container's children.
|
|
8
|
+
* NOT a dnd framework: no nesting, no cross-container moves, no spans or
|
|
9
|
+
* collision packing - when that day comes, take a ready-made library. */
|
|
10
|
+
export type tReorderOptions = {
|
|
11
|
+
/** keys in render order; the container's children must correspond 1:1 */
|
|
12
|
+
order: string[];
|
|
13
|
+
/** ONE commit on drop; skipped when nothing moved */
|
|
14
|
+
commit: (next: string[]) => void;
|
|
15
|
+
/** simulated commit - the preview shows EXACTLY what commit will produce.
|
|
16
|
+
* Default: plain splice. Consumers with pinning rules (fixed items etc.)
|
|
17
|
+
* pass their own so preview == drop by construction. */
|
|
18
|
+
move?: (order: string[], key: string, to: number) => string[];
|
|
19
|
+
/** false = this key cannot start a drag (default: all can) */
|
|
20
|
+
canDrag?: (key: string) => boolean;
|
|
21
|
+
/** 'slots' (default): blocks glide between the slot centers measured at
|
|
22
|
+
* drag start - exact when all blocks are equal-sized.
|
|
23
|
+
* 'measure': FLIP - the preview order is applied via CSS `order`, the real
|
|
24
|
+
* layout is read and reverted in one synchronous pass (the intermediate
|
|
25
|
+
* state never paints) - exact for ANY block sizes and wrapping, requires
|
|
26
|
+
* a flex/grid container. */
|
|
27
|
+
preview?: 'slots' | 'measure';
|
|
28
|
+
/** hold before the drag starts (touch-friendly fields); default 0 */
|
|
29
|
+
holdMs?: number;
|
|
30
|
+
};
|
|
31
|
+
export type tReorderItem = {
|
|
32
|
+
/** spread on the block element */
|
|
33
|
+
props: {
|
|
34
|
+
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
|
35
|
+
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
|
36
|
+
};
|
|
37
|
+
/** transform while a drag is active (undefined otherwise) */
|
|
38
|
+
style?: React.CSSProperties;
|
|
39
|
+
/** this block is the one under the pointer */
|
|
40
|
+
dragging: boolean;
|
|
41
|
+
/** some drag is active - the consumer adds its transition class/style on non-dragged blocks */
|
|
42
|
+
active: boolean;
|
|
43
|
+
};
|
|
44
|
+
export declare function useReorder<E extends HTMLElement = HTMLDivElement>(o: tReorderOptions): {
|
|
45
|
+
listRef: React.RefObject<E | null>;
|
|
46
|
+
item: (key: string) => tReorderItem;
|
|
47
|
+
dragKey: string | null;
|
|
48
|
+
preview: string[] | null;
|
|
49
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useDraggableApi } from './useDraggable';
|
|
3
|
+
export function useReorder(o) {
|
|
4
|
+
const listRef = useRef(null);
|
|
5
|
+
const [dragKey, setDragKey] = useState(null);
|
|
6
|
+
const slotsRef = useRef([]); // child centers at drag start (local px)
|
|
7
|
+
const startRef = useRef([]); // child top-lefts at drag start (local px)
|
|
8
|
+
// Pointer deltas are viewport px, layout is local px: under a scaled ancestor
|
|
9
|
+
// (client styling, zoomed containers) they diverge - normalize by the ratio.
|
|
10
|
+
const scaleRef = useRef(1);
|
|
11
|
+
const measureRef = useRef(null);
|
|
12
|
+
const move = o.move ?? function plainSplice(order, key, to) {
|
|
13
|
+
const next = order.slice();
|
|
14
|
+
const from = next.indexOf(key);
|
|
15
|
+
if (from == -1)
|
|
16
|
+
return next;
|
|
17
|
+
next.splice(from, 1);
|
|
18
|
+
next.splice(Math.max(0, Math.min(next.length, to)), 0, key);
|
|
19
|
+
return next;
|
|
20
|
+
};
|
|
21
|
+
const kids = () => Array.from(listRef.current?.children ?? []);
|
|
22
|
+
/** viewport px -> local px */
|
|
23
|
+
const local = (v) => v / scaleRef.current;
|
|
24
|
+
/** Nearest START-slot center to the dragged block's center. Targeting always
|
|
25
|
+
* runs against the slots measured at drag start - computing it against a
|
|
26
|
+
* live-reflowing layout oscillates at boundaries; that failure mode is
|
|
27
|
+
* designed out. */
|
|
28
|
+
function dragTarget(from, dx, dy) {
|
|
29
|
+
const slots = slotsRef.current;
|
|
30
|
+
const start = slots[from];
|
|
31
|
+
if (!start)
|
|
32
|
+
return from;
|
|
33
|
+
const x = start.x + dx, y = start.y + dy;
|
|
34
|
+
let best = from, bestD = Infinity;
|
|
35
|
+
slots.forEach((c, i) => {
|
|
36
|
+
const d = (c.x - x) ** 2 + (c.y - y) ** 2;
|
|
37
|
+
if (d < bestD) {
|
|
38
|
+
bestD = d;
|
|
39
|
+
best = i;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return best;
|
|
43
|
+
}
|
|
44
|
+
const drag = useDraggableApi({ holdMs: o.holdMs ?? 0, onDragEnd: function commitOrder(final) {
|
|
45
|
+
setDragKey(null);
|
|
46
|
+
measureRef.current = null;
|
|
47
|
+
if (dragKey == null)
|
|
48
|
+
return;
|
|
49
|
+
const from = o.order.indexOf(dragKey);
|
|
50
|
+
if (from == -1)
|
|
51
|
+
return;
|
|
52
|
+
const next = move(o.order, dragKey, dragTarget(from, local(final.x), local(final.y)));
|
|
53
|
+
if (next.some((k, i) => k != o.order[i]))
|
|
54
|
+
o.commit(next);
|
|
55
|
+
} });
|
|
56
|
+
function beginDrag(key, e) {
|
|
57
|
+
if (o.canDrag && !o.canDrag(key))
|
|
58
|
+
return false;
|
|
59
|
+
// interactive children stay clickable (checkboxes in rows etc.)
|
|
60
|
+
if (e.target.closest('input, button, select, textarea, a'))
|
|
61
|
+
return false;
|
|
62
|
+
const list = listRef.current;
|
|
63
|
+
if (!list)
|
|
64
|
+
return false;
|
|
65
|
+
scaleRef.current = list.offsetWidth ? list.getBoundingClientRect().width / list.offsetWidth : 1;
|
|
66
|
+
// offsetLeft/Top, NOT getBoundingClientRect: offsets are pure layout-box
|
|
67
|
+
// positions - transforms (incl. mid-flight transitions) never leak in
|
|
68
|
+
const els = kids();
|
|
69
|
+
slotsRef.current = els.map(el => ({ x: el.offsetLeft + el.offsetWidth / 2, y: el.offsetTop + el.offsetHeight / 2 }));
|
|
70
|
+
startRef.current = els.map(el => ({ x: el.offsetLeft, y: el.offsetTop }));
|
|
71
|
+
measureRef.current = null;
|
|
72
|
+
setDragKey(key);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/** FLIP: apply the preview order via CSS `order`, read the real layout,
|
|
76
|
+
* revert - one synchronous pass between frames, so the intermediate state
|
|
77
|
+
* never paints and CSS does the wrapping math for us. Cached per target
|
|
78
|
+
* (targets change rarely, not per mousemove). Positions are read via
|
|
79
|
+
* offsetLeft/Top, NEVER getBoundingClientRect: rects include transforms,
|
|
80
|
+
* and mid-drag the blocks carry mid-flight transition values (setting
|
|
81
|
+
* style.transform='none' does not stop a running transition within the
|
|
82
|
+
* same synchronous pass) - measuring through them accumulates the previous
|
|
83
|
+
* preview's offsets on every re-measure and the blocks fly apart. Offsets
|
|
84
|
+
* are pure layout-box positions, immune to all of that. */
|
|
85
|
+
function measuredPositions(preview, target) {
|
|
86
|
+
if (measureRef.current?.target == target)
|
|
87
|
+
return measureRef.current.pos;
|
|
88
|
+
const els = kids();
|
|
89
|
+
const saved = els.map(el => el.style.order);
|
|
90
|
+
els.forEach((el, i) => el.style.order = String(preview.indexOf(o.order[i])));
|
|
91
|
+
const pos = els.map(el => ({ x: el.offsetLeft, y: el.offsetTop }));
|
|
92
|
+
els.forEach((el, i) => el.style.order = saved[i]);
|
|
93
|
+
measureRef.current = { target, pos };
|
|
94
|
+
return pos;
|
|
95
|
+
}
|
|
96
|
+
const from = dragKey != null ? o.order.indexOf(dragKey) : -1;
|
|
97
|
+
const target = from != -1 ? dragTarget(from, local(drag.position.x), local(drag.position.y)) : -1;
|
|
98
|
+
const preview = from != -1 && dragKey != null ? move(o.order, dragKey, target) : null;
|
|
99
|
+
function item(key) {
|
|
100
|
+
const active = preview != null;
|
|
101
|
+
const dragging = key == dragKey;
|
|
102
|
+
let style;
|
|
103
|
+
if (active) {
|
|
104
|
+
const i = o.order.indexOf(key);
|
|
105
|
+
if (dragging) {
|
|
106
|
+
style = { transform: `translate(${local(drag.position.x)}px, ${local(drag.position.y)}px)` };
|
|
107
|
+
}
|
|
108
|
+
else if (o.preview == 'measure') {
|
|
109
|
+
const pos = measuredPositions(preview, target);
|
|
110
|
+
const a = startRef.current[i], b = pos[i];
|
|
111
|
+
if (a && b && (a.x != b.x || a.y != b.y))
|
|
112
|
+
style = { transform: `translate(${b.x - a.x}px, ${b.y - a.y}px)` };
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const pi = preview.indexOf(key);
|
|
116
|
+
const a = slotsRef.current[i], b = slotsRef.current[pi];
|
|
117
|
+
if (pi != i && a && b)
|
|
118
|
+
style = { transform: `translate(${b.x - a.x}px, ${b.y - a.y}px)` };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
props: {
|
|
123
|
+
onMouseDown(e) { if (e.button == 0 && beginDrag(key, e))
|
|
124
|
+
drag.props.onMouseDown(e); },
|
|
125
|
+
onTouchStart(e) { if (beginDrag(key, e))
|
|
126
|
+
drag.props.onTouchStart(e); },
|
|
127
|
+
},
|
|
128
|
+
style,
|
|
129
|
+
dragging,
|
|
130
|
+
active,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { listRef, item, dragKey, preview };
|
|
134
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { tReorderItem } from './useReorder';
|
|
2
|
+
/** useReorderBoard - the columns extension of useReorder: keyed blocks live in
|
|
3
|
+
* VERTICAL columns (plain consumer divs), one block drags between/within them,
|
|
4
|
+
* ONE commit on drop. Column "gravity" is pure consumer CSS (justify-content:
|
|
5
|
+
* flex-start packs up, flex-end packs down) - the hook never knows it, it
|
|
6
|
+
* measures the real layout. Columns can appear/disappear between drags (the
|
|
7
|
+
* ref callback registry is live); the set is frozen for the duration of one
|
|
8
|
+
* drag. Children of a column div must be exactly its items, 1:1, in order.
|
|
9
|
+
* Same non-goals as useReorder: no nesting, no spans/collision packing, no
|
|
10
|
+
* autoscroll - that day is a ready-made dnd library. */
|
|
11
|
+
export type tBoardPos = {
|
|
12
|
+
col: string;
|
|
13
|
+
index: number;
|
|
14
|
+
};
|
|
15
|
+
export type tBoardColumn = {
|
|
16
|
+
key: string;
|
|
17
|
+
items: string[];
|
|
18
|
+
};
|
|
19
|
+
export type tReorderBoardOptions = {
|
|
20
|
+
/** columns in board order; each column's div children must render items 1:1 */
|
|
21
|
+
columns: tBoardColumn[];
|
|
22
|
+
/** ONE commit on drop; skipped when nothing moved */
|
|
23
|
+
commit: (next: tBoardColumn[]) => void;
|
|
24
|
+
/** false = this key cannot start a drag (default: all can) */
|
|
25
|
+
canDrag?: (key: string) => boolean;
|
|
26
|
+
/** hold before the drag starts; default 0 */
|
|
27
|
+
holdMs?: number;
|
|
28
|
+
/** the block was grabbed */
|
|
29
|
+
onDragStart?: (e: {
|
|
30
|
+
key: string;
|
|
31
|
+
from: tBoardPos;
|
|
32
|
+
}) => void;
|
|
33
|
+
/** every pointer move while dragging; position = pointer delta in local px */
|
|
34
|
+
onDragMove?: (e: {
|
|
35
|
+
key: string;
|
|
36
|
+
over: tBoardPos;
|
|
37
|
+
position: {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
};
|
|
41
|
+
}) => void;
|
|
42
|
+
/** the target slot changed (compare prev.col != over.col for column crossings) */
|
|
43
|
+
onOverChange?: (e: {
|
|
44
|
+
key: string;
|
|
45
|
+
over: tBoardPos;
|
|
46
|
+
prev: tBoardPos | null;
|
|
47
|
+
}) => void;
|
|
48
|
+
/** drop; committed=false when nothing moved (plain click) */
|
|
49
|
+
onDragEnd?: (e: {
|
|
50
|
+
key: string;
|
|
51
|
+
from: tBoardPos;
|
|
52
|
+
over: tBoardPos;
|
|
53
|
+
committed: boolean;
|
|
54
|
+
}) => void;
|
|
55
|
+
};
|
|
56
|
+
export declare function useReorderBoard(o: tReorderBoardOptions): {
|
|
57
|
+
columnRef: (col: string) => (el: HTMLElement | null) => void;
|
|
58
|
+
item: (key: string) => tReorderItem;
|
|
59
|
+
dragKey: string | null;
|
|
60
|
+
over: tBoardPos | null;
|
|
61
|
+
};
|