@nikala-ui/hooks 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -1
- package/src/create-app-updater.ts +272 -0
- package/src/create-document-tabs.ts +267 -0
- package/src/create-global-shortcut.ts +140 -0
- package/src/create-tauri-window.ts +628 -0
- package/src/create-tiptap-editor.ts +326 -0
- package/src/index.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nikala-ui/hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Reactive SolidJS primitives and primitives for Nikala UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -23,5 +23,26 @@
|
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"solid-js": "^1.9.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@tiptap/core": "^3.31.0",
|
|
29
|
+
"@tiptap/extension-character-count": "^3.31.0",
|
|
30
|
+
"@tiptap/extension-highlight": "^3.31.0",
|
|
31
|
+
"@tiptap/extension-image": "^3.31.0",
|
|
32
|
+
"@tiptap/extension-link": "^3.31.0",
|
|
33
|
+
"@tiptap/extension-placeholder": "^3.31.0",
|
|
34
|
+
"@tiptap/extension-subscript": "^3.31.0",
|
|
35
|
+
"@tiptap/extension-superscript": "^3.31.0",
|
|
36
|
+
"@tiptap/extension-table": "^3.31.0",
|
|
37
|
+
"@tiptap/extension-table-cell": "^3.31.0",
|
|
38
|
+
"@tiptap/extension-table-header": "^3.31.0",
|
|
39
|
+
"@tiptap/extension-table-row": "^3.31.0",
|
|
40
|
+
"@tiptap/extension-task-item": "^3.31.0",
|
|
41
|
+
"@tiptap/extension-task-list": "^3.31.0",
|
|
42
|
+
"@tiptap/extension-text-align": "^3.31.0",
|
|
43
|
+
"@tiptap/extension-typography": "^3.31.0",
|
|
44
|
+
"@tiptap/extension-underline": "^3.31.0",
|
|
45
|
+
"@tiptap/starter-kit": "^3.31.0",
|
|
46
|
+
"tiptap-markdown": "^0.9.0"
|
|
26
47
|
}
|
|
27
48
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { createSignal, onMount, type Accessor } from "solid-js";
|
|
2
|
+
import { isTauriEnvironment } from "./create-tauri-window";
|
|
3
|
+
|
|
4
|
+
export type UpdaterStatus =
|
|
5
|
+
| "idle"
|
|
6
|
+
| "checking"
|
|
7
|
+
| "available"
|
|
8
|
+
| "up-to-date"
|
|
9
|
+
| "downloading"
|
|
10
|
+
| "downloaded"
|
|
11
|
+
| "error";
|
|
12
|
+
|
|
13
|
+
export interface UpdateManifestInfo {
|
|
14
|
+
version: string;
|
|
15
|
+
currentVersion?: string;
|
|
16
|
+
date?: string;
|
|
17
|
+
body?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UpdateProgressInfo {
|
|
21
|
+
downloaded: number;
|
|
22
|
+
total: number;
|
|
23
|
+
percentage: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CreateAppUpdaterOptions {
|
|
27
|
+
/** Automatically check for updates on mount. Defaults to false. */
|
|
28
|
+
autoCheck?: boolean;
|
|
29
|
+
/** Current application version fallback if not detected. */
|
|
30
|
+
currentVersion?: string;
|
|
31
|
+
/** Callback fired when an update is available. */
|
|
32
|
+
onUpdateAvailable?: (info: UpdateManifestInfo) => void;
|
|
33
|
+
/** Callback fired when download finishes. */
|
|
34
|
+
onDownloadFinished?: () => void;
|
|
35
|
+
/** Callback fired on update check or download error. */
|
|
36
|
+
onError?: (error: Error | string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CreateAppUpdaterReturn {
|
|
40
|
+
/** Current lifecycle status of the updater. */
|
|
41
|
+
status: Accessor<UpdaterStatus>;
|
|
42
|
+
/** Update metadata (new version, release notes, release date). */
|
|
43
|
+
updateInfo: Accessor<UpdateManifestInfo | null>;
|
|
44
|
+
/** Download progress (bytes downloaded, total bytes, percentage). */
|
|
45
|
+
progress: Accessor<UpdateProgressInfo>;
|
|
46
|
+
/** Error message if an operation failed. */
|
|
47
|
+
error: Accessor<string | null>;
|
|
48
|
+
/** Whether an update is currently being checked or downloaded. */
|
|
49
|
+
isLoading: Accessor<boolean>;
|
|
50
|
+
/** Check update endpoint for a new release. */
|
|
51
|
+
checkForUpdates: () => Promise<boolean>;
|
|
52
|
+
/** Download and install the available update. */
|
|
53
|
+
downloadAndInstall: () => Promise<void>;
|
|
54
|
+
/** Restart application to apply the downloaded update. */
|
|
55
|
+
relaunch: () => Promise<void>;
|
|
56
|
+
/** Dismiss current update prompt or clear error state. */
|
|
57
|
+
dismiss: () => void;
|
|
58
|
+
/** Simulate an update cycle in web / playground preview environments. */
|
|
59
|
+
simulateUpdate: (mockInfo?: Partial<UpdateManifestInfo>) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SolidJS reactive primitive for controlling and observing Tauri v2 application updates.
|
|
64
|
+
* Integrates with @tauri-apps/plugin-updater with full web simulation support.
|
|
65
|
+
*/
|
|
66
|
+
export function createAppUpdater(
|
|
67
|
+
options: CreateAppUpdaterOptions = {}
|
|
68
|
+
): CreateAppUpdaterReturn {
|
|
69
|
+
const [status, setStatus] = createSignal<UpdaterStatus>("idle");
|
|
70
|
+
const [updateInfo, setUpdateInfo] = createSignal<UpdateManifestInfo | null>(null);
|
|
71
|
+
const [progress, setProgress] = createSignal<UpdateProgressInfo>({
|
|
72
|
+
downloaded: 0,
|
|
73
|
+
total: 0,
|
|
74
|
+
percentage: 0,
|
|
75
|
+
});
|
|
76
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
77
|
+
|
|
78
|
+
let activeTauriUpdate: any = null;
|
|
79
|
+
let simulatedTimer: any = null;
|
|
80
|
+
|
|
81
|
+
const getTauriUpdaterPlugin = async () => {
|
|
82
|
+
if (typeof window === "undefined") return null;
|
|
83
|
+
try {
|
|
84
|
+
if ((window as any).__TAURI__?.updater) {
|
|
85
|
+
return (window as any).__TAURI__.updater;
|
|
86
|
+
}
|
|
87
|
+
const moduleName = "@tauri-apps/plugin-updater";
|
|
88
|
+
// @ts-ignore - Optional runtime dependency in Tauri apps
|
|
89
|
+
const plugin = await import(/* @vite-ignore */ moduleName).catch(() => null);
|
|
90
|
+
return plugin;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const getTauriProcessPlugin = async () => {
|
|
97
|
+
if (typeof window === "undefined") return null;
|
|
98
|
+
try {
|
|
99
|
+
if ((window as any).__TAURI__?.process) {
|
|
100
|
+
return (window as any).__TAURI__.process;
|
|
101
|
+
}
|
|
102
|
+
const moduleName = "@tauri-apps/plugin-process";
|
|
103
|
+
// @ts-ignore - Optional runtime dependency in Tauri apps
|
|
104
|
+
const plugin = await import(/* @vite-ignore */ moduleName).catch(() => null);
|
|
105
|
+
return plugin;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const checkForUpdates = async (): Promise<boolean> => {
|
|
112
|
+
setStatus("checking");
|
|
113
|
+
setError(null);
|
|
114
|
+
|
|
115
|
+
if (isTauriEnvironment()) {
|
|
116
|
+
try {
|
|
117
|
+
const updaterPlugin = await getTauriUpdaterPlugin();
|
|
118
|
+
if (updaterPlugin?.check) {
|
|
119
|
+
const update = await updaterPlugin.check();
|
|
120
|
+
if (update?.available) {
|
|
121
|
+
activeTauriUpdate = update;
|
|
122
|
+
const info: UpdateManifestInfo = {
|
|
123
|
+
version: update.version,
|
|
124
|
+
currentVersion: update.currentVersion || options.currentVersion || "v1.0.0",
|
|
125
|
+
date: update.date,
|
|
126
|
+
body: update.body || "Bug fixes and performance enhancements.",
|
|
127
|
+
};
|
|
128
|
+
setUpdateInfo(info);
|
|
129
|
+
setStatus("available");
|
|
130
|
+
options.onUpdateAvailable?.(info);
|
|
131
|
+
return true;
|
|
132
|
+
} else {
|
|
133
|
+
setStatus("up-to-date");
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
const errMsg = err?.message || String(err);
|
|
139
|
+
setError(errMsg);
|
|
140
|
+
setStatus("error");
|
|
141
|
+
options.onError?.(errMsg);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// In web preview / non-tauri mode, simulate checking
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
148
|
+
setStatus("up-to-date");
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const downloadAndInstall = async (): Promise<void> => {
|
|
153
|
+
if (status() !== "available" && status() !== "error") return;
|
|
154
|
+
setStatus("downloading");
|
|
155
|
+
setError(null);
|
|
156
|
+
setProgress({ downloaded: 0, total: 100, percentage: 0 });
|
|
157
|
+
|
|
158
|
+
if (isTauriEnvironment() && activeTauriUpdate?.downloadAndInstall) {
|
|
159
|
+
try {
|
|
160
|
+
let downloadedBytes = 0;
|
|
161
|
+
let contentLength = 0;
|
|
162
|
+
|
|
163
|
+
await activeTauriUpdate.downloadAndInstall((event: any) => {
|
|
164
|
+
if (event.event === "Started") {
|
|
165
|
+
contentLength = event.data?.contentLength || 100;
|
|
166
|
+
} else if (event.event === "Progress") {
|
|
167
|
+
downloadedBytes += event.data?.chunkLength || 0;
|
|
168
|
+
const pct = contentLength > 0 ? Math.min(100, Math.round((downloadedBytes / contentLength) * 100)) : 50;
|
|
169
|
+
setProgress({
|
|
170
|
+
downloaded: downloadedBytes,
|
|
171
|
+
total: contentLength,
|
|
172
|
+
percentage: pct,
|
|
173
|
+
});
|
|
174
|
+
} else if (event.event === "Finished") {
|
|
175
|
+
setProgress({
|
|
176
|
+
downloaded: contentLength,
|
|
177
|
+
total: contentLength,
|
|
178
|
+
percentage: 100,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
setStatus("downloaded");
|
|
184
|
+
options.onDownloadFinished?.();
|
|
185
|
+
return;
|
|
186
|
+
} catch (err: any) {
|
|
187
|
+
const errMsg = err?.message || String(err);
|
|
188
|
+
setError(errMsg);
|
|
189
|
+
setStatus("error");
|
|
190
|
+
options.onError?.(errMsg);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Web simulation mode with smooth progress step
|
|
196
|
+
let currentPct = 0;
|
|
197
|
+
simulatedTimer = setInterval(() => {
|
|
198
|
+
currentPct += 15;
|
|
199
|
+
if (currentPct >= 100) {
|
|
200
|
+
clearInterval(simulatedTimer);
|
|
201
|
+
setProgress({ downloaded: 45.8 * 1024 * 1024, total: 45.8 * 1024 * 1024, percentage: 100 });
|
|
202
|
+
setStatus("downloaded");
|
|
203
|
+
options.onDownloadFinished?.();
|
|
204
|
+
} else {
|
|
205
|
+
const downloaded = (45.8 * 1024 * 1024 * currentPct) / 100;
|
|
206
|
+
setProgress({ downloaded, total: 45.8 * 1024 * 1024, percentage: currentPct });
|
|
207
|
+
}
|
|
208
|
+
}, 250);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const relaunch = async (): Promise<void> => {
|
|
212
|
+
if (isTauriEnvironment()) {
|
|
213
|
+
try {
|
|
214
|
+
const processPlugin = await getTauriProcessPlugin();
|
|
215
|
+
if (processPlugin?.relaunch) {
|
|
216
|
+
await processPlugin.relaunch();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// Fallback
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (typeof window !== "undefined") {
|
|
225
|
+
window.location.reload();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const dismiss = (): void => {
|
|
230
|
+
if (simulatedTimer) clearInterval(simulatedTimer);
|
|
231
|
+
setStatus("idle");
|
|
232
|
+
setError(null);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const simulateUpdate = (mockInfo?: Partial<UpdateManifestInfo>): void => {
|
|
236
|
+
if (simulatedTimer) clearInterval(simulatedTimer);
|
|
237
|
+
const info: UpdateManifestInfo = {
|
|
238
|
+
version: mockInfo?.version || "v1.2.0",
|
|
239
|
+
currentVersion: mockInfo?.currentVersion || options.currentVersion || "v1.0.0",
|
|
240
|
+
date: mockInfo?.date || new Date().toISOString().split("T")[0],
|
|
241
|
+
body:
|
|
242
|
+
mockInfo?.body ||
|
|
243
|
+
"### What's New in v1.2.0\n- Added native Window Titlebar tabs\n- Enhanced Tauri v2 capability security\n- Optimized SolidJS signal reactivity\n- Fixed titlebar drag region jitter on Linux",
|
|
244
|
+
};
|
|
245
|
+
setUpdateInfo(info);
|
|
246
|
+
setStatus("available");
|
|
247
|
+
setError(null);
|
|
248
|
+
setProgress({ downloaded: 0, total: 100, percentage: 0 });
|
|
249
|
+
options.onUpdateAvailable?.(info);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
onMount(() => {
|
|
253
|
+
if (options.autoCheck) {
|
|
254
|
+
checkForUpdates();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const isLoading = () => status() === "checking" || status() === "downloading";
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
status,
|
|
262
|
+
updateInfo,
|
|
263
|
+
progress,
|
|
264
|
+
error,
|
|
265
|
+
isLoading,
|
|
266
|
+
checkForUpdates,
|
|
267
|
+
downloadAndInstall,
|
|
268
|
+
relaunch,
|
|
269
|
+
dismiss,
|
|
270
|
+
simulateUpdate,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSignal,
|
|
3
|
+
createMemo,
|
|
4
|
+
onMount,
|
|
5
|
+
onCleanup,
|
|
6
|
+
type Accessor,
|
|
7
|
+
} from "solid-js";
|
|
8
|
+
|
|
9
|
+
export interface TabItem<T = any> {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
icon?: any;
|
|
13
|
+
isDirty?: boolean;
|
|
14
|
+
isPinned?: boolean;
|
|
15
|
+
closable?: boolean;
|
|
16
|
+
data?: T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateDocumentTabsOptions<T = any> {
|
|
20
|
+
/** Initial array of tab items. */
|
|
21
|
+
initialTabs?: TabItem<T>[];
|
|
22
|
+
/** Default active tab id. Defaults to first tab if available. */
|
|
23
|
+
defaultActiveId?: string;
|
|
24
|
+
/** Whether to enable keyboard shortcuts (Ctrl/Cmd+W to close active, Ctrl/Cmd+T for new tab). */
|
|
25
|
+
enableKeybindings?: boolean;
|
|
26
|
+
/** Callback triggered when the active tab changes. */
|
|
27
|
+
onTabChange?: (tab: TabItem<T> | undefined) => void;
|
|
28
|
+
/** Callback triggered before or when a tab closes. Return false to prevent closing. */
|
|
29
|
+
onTabClose?: (tab: TabItem<T>) => boolean | void;
|
|
30
|
+
/** Callback triggered when a new tab is created. */
|
|
31
|
+
onTabAdd?: (tab: TabItem<T>) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CreateDocumentTabsReturn<T = any> {
|
|
35
|
+
/** All tabs list. */
|
|
36
|
+
tabs: Accessor<TabItem<T>[]>;
|
|
37
|
+
/** Currently active tab ID. */
|
|
38
|
+
activeTabId: Accessor<string>;
|
|
39
|
+
/** Currently active tab object. */
|
|
40
|
+
activeTab: Accessor<TabItem<T> | undefined>;
|
|
41
|
+
/** Pinned tabs only. */
|
|
42
|
+
pinnedTabs: Accessor<TabItem<T>[]>;
|
|
43
|
+
/** Standard unpinned tabs. */
|
|
44
|
+
unpinnedTabs: Accessor<TabItem<T>[]>;
|
|
45
|
+
/** Whether any open tab has unsaved changes (isDirty). */
|
|
46
|
+
hasDirtyTabs: Accessor<boolean>;
|
|
47
|
+
/** Count of total open tabs. */
|
|
48
|
+
count: Accessor<number>;
|
|
49
|
+
/** Add a new tab to the list. */
|
|
50
|
+
addTab: (tab: TabItem<T>, activate?: boolean) => void;
|
|
51
|
+
/** Close a tab by ID with auto-switching to adjacent tab. */
|
|
52
|
+
closeTab: (id: string) => boolean;
|
|
53
|
+
/** Set the active tab. */
|
|
54
|
+
setActiveTab: (id: string) => void;
|
|
55
|
+
/** Mark or unmark a tab as dirty (unsaved changes). */
|
|
56
|
+
markDirty: (id: string, isDirty?: boolean) => void;
|
|
57
|
+
/** Toggle pinned state of a tab. */
|
|
58
|
+
togglePin: (id: string) => void;
|
|
59
|
+
/** Close all tabs except the specified one. */
|
|
60
|
+
closeOthers: (id: string) => void;
|
|
61
|
+
/** Close all open unpinned tabs. */
|
|
62
|
+
closeAll: (includePinned?: boolean) => void;
|
|
63
|
+
/** Switch to the next tab in sequence. */
|
|
64
|
+
nextTab: () => void;
|
|
65
|
+
/** Switch to the previous tab in sequence. */
|
|
66
|
+
prevTab: () => void;
|
|
67
|
+
/** Reorder tabs from one position to another. */
|
|
68
|
+
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findLastPinnedIndex<T>(items: TabItem<T>[]): number {
|
|
72
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
73
|
+
if (items[i].isPinned) return i;
|
|
74
|
+
}
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* SolidJS reactive primitive for managing multi-document tabs, editor buffers, and browser tab states.
|
|
80
|
+
*/
|
|
81
|
+
export function createDocumentTabs<T = any>(
|
|
82
|
+
options: CreateDocumentTabsOptions<T> = {}
|
|
83
|
+
): CreateDocumentTabsReturn<T> {
|
|
84
|
+
const initial = options.initialTabs || [];
|
|
85
|
+
const [tabs, setTabs] = createSignal<TabItem<T>[]>(initial);
|
|
86
|
+
const [activeTabId, setActiveTabId] = createSignal<string>(
|
|
87
|
+
options.defaultActiveId || (initial.length > 0 ? initial[0].id : "")
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const activeTab = createMemo(() => tabs().find((t) => t.id === activeTabId()));
|
|
91
|
+
const pinnedTabs = createMemo(() => tabs().filter((t) => t.isPinned));
|
|
92
|
+
const unpinnedTabs = createMemo(() => tabs().filter((t) => !t.isPinned));
|
|
93
|
+
const hasDirtyTabs = createMemo(() => tabs().some((t) => t.isDirty));
|
|
94
|
+
const count = createMemo(() => tabs().length);
|
|
95
|
+
|
|
96
|
+
const setActive = (id: string) => {
|
|
97
|
+
const target = tabs().find((t) => t.id === id);
|
|
98
|
+
if (target) {
|
|
99
|
+
setActiveTabId(id);
|
|
100
|
+
options.onTabChange?.(target);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const addTab = (tab: TabItem<T>, activate = true) => {
|
|
105
|
+
const existing = tabs().find((t) => t.id === tab.id);
|
|
106
|
+
if (existing) {
|
|
107
|
+
if (activate) setActive(tab.id);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setTabs((prev) => {
|
|
112
|
+
if (tab.isPinned) {
|
|
113
|
+
const lastPinnedIdx = findLastPinnedIndex(prev);
|
|
114
|
+
if (lastPinnedIdx === -1) return [tab, ...prev];
|
|
115
|
+
const next = [...prev];
|
|
116
|
+
next.splice(lastPinnedIdx + 1, 0, tab);
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
return [...prev, tab];
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (activate) {
|
|
123
|
+
setActiveTabId(tab.id);
|
|
124
|
+
options.onTabChange?.(tab);
|
|
125
|
+
}
|
|
126
|
+
options.onTabAdd?.(tab);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const closeTab = (id: string): boolean => {
|
|
130
|
+
const currentTabs = tabs();
|
|
131
|
+
const tabToClose = currentTabs.find((t) => t.id === id);
|
|
132
|
+
if (!tabToClose) return false;
|
|
133
|
+
|
|
134
|
+
if (options.onTabClose && options.onTabClose(tabToClose) === false) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const closeIdx = currentTabs.findIndex((t) => t.id === id);
|
|
139
|
+
const nextTabs = currentTabs.filter((t) => t.id !== id);
|
|
140
|
+
setTabs(nextTabs);
|
|
141
|
+
|
|
142
|
+
if (activeTabId() === id) {
|
|
143
|
+
if (nextTabs.length === 0) {
|
|
144
|
+
setActiveTabId("");
|
|
145
|
+
options.onTabChange?.(undefined);
|
|
146
|
+
} else {
|
|
147
|
+
const nextIdx = Math.min(closeIdx, nextTabs.length - 1);
|
|
148
|
+
const nextActive = nextTabs[nextIdx];
|
|
149
|
+
setActiveTabId(nextActive.id);
|
|
150
|
+
options.onTabChange?.(nextActive);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const markDirty = (id: string, isDirty = true) => {
|
|
157
|
+
setTabs((prev) =>
|
|
158
|
+
prev.map((t) => (t.id === id ? { ...t, isDirty } : t))
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const togglePin = (id: string) => {
|
|
163
|
+
setTabs((prev) => {
|
|
164
|
+
const target = prev.find((t) => t.id === id);
|
|
165
|
+
if (!target) return prev;
|
|
166
|
+
const updated = { ...target, isPinned: !target.isPinned };
|
|
167
|
+
const remaining = prev.filter((t) => t.id !== id);
|
|
168
|
+
|
|
169
|
+
if (updated.isPinned) {
|
|
170
|
+
const lastPinnedIdx = findLastPinnedIndex(remaining);
|
|
171
|
+
if (lastPinnedIdx === -1) return [updated, ...remaining];
|
|
172
|
+
remaining.splice(lastPinnedIdx + 1, 0, updated);
|
|
173
|
+
return remaining;
|
|
174
|
+
}
|
|
175
|
+
return [...remaining, updated];
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const closeOthers = (id: string) => {
|
|
180
|
+
setTabs((prev) => prev.filter((t) => t.id === id || t.isPinned));
|
|
181
|
+
setActive(id);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const closeAll = (includePinned = false) => {
|
|
185
|
+
if (includePinned) {
|
|
186
|
+
setTabs([]);
|
|
187
|
+
setActiveTabId("");
|
|
188
|
+
options.onTabChange?.(undefined);
|
|
189
|
+
} else {
|
|
190
|
+
const pinned = tabs().filter((t) => t.isPinned);
|
|
191
|
+
setTabs(pinned);
|
|
192
|
+
if (pinned.length > 0) {
|
|
193
|
+
setActiveTabId(pinned[0].id);
|
|
194
|
+
options.onTabChange?.(pinned[0]);
|
|
195
|
+
} else {
|
|
196
|
+
setActiveTabId("");
|
|
197
|
+
options.onTabChange?.(undefined);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const nextTab = () => {
|
|
203
|
+
const list = tabs();
|
|
204
|
+
if (list.length <= 1) return;
|
|
205
|
+
const currentIdx = list.findIndex((t) => t.id === activeTabId());
|
|
206
|
+
const nextIdx = (currentIdx + 1) % list.length;
|
|
207
|
+
setActive(list[nextIdx].id);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const prevTab = () => {
|
|
211
|
+
const list = tabs();
|
|
212
|
+
if (list.length <= 1) return;
|
|
213
|
+
const currentIdx = list.findIndex((t) => t.id === activeTabId());
|
|
214
|
+
const prevIdx = (currentIdx - 1 + list.length) % list.length;
|
|
215
|
+
setActive(list[prevIdx].id);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const reorderTabs = (fromIndex: number, toIndex: number) => {
|
|
219
|
+
setTabs((prev) => {
|
|
220
|
+
if (fromIndex < 0 || fromIndex >= prev.length || toIndex < 0 || toIndex >= prev.length) {
|
|
221
|
+
return prev;
|
|
222
|
+
}
|
|
223
|
+
const clone = [...prev];
|
|
224
|
+
const [moved] = clone.splice(fromIndex, 1);
|
|
225
|
+
clone.splice(toIndex, 0, moved);
|
|
226
|
+
return clone;
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
onMount(() => {
|
|
231
|
+
if (!options.enableKeybindings || typeof window === "undefined") return;
|
|
232
|
+
|
|
233
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
234
|
+
const isCmdOrCtrl = e.metaKey || e.ctrlKey;
|
|
235
|
+
if (isCmdOrCtrl && (e.key === "w" || e.key === "W")) {
|
|
236
|
+
const active = activeTabId();
|
|
237
|
+
if (active) {
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
closeTab(active);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
245
|
+
onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
tabs,
|
|
250
|
+
activeTabId,
|
|
251
|
+
activeTab,
|
|
252
|
+
pinnedTabs,
|
|
253
|
+
unpinnedTabs,
|
|
254
|
+
hasDirtyTabs,
|
|
255
|
+
count,
|
|
256
|
+
addTab,
|
|
257
|
+
closeTab,
|
|
258
|
+
setActiveTab: setActive,
|
|
259
|
+
markDirty,
|
|
260
|
+
togglePin,
|
|
261
|
+
closeOthers,
|
|
262
|
+
closeAll,
|
|
263
|
+
nextTab,
|
|
264
|
+
prevTab,
|
|
265
|
+
reorderTabs,
|
|
266
|
+
};
|
|
267
|
+
}
|