@wildwinter/app-shell 0.32.0 → 0.33.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/dist/app-store.cjs +9 -3
- package/dist/app-store.cjs.map +1 -1
- package/dist/app-store.js +9 -3
- package/dist/app-store.js.map +1 -1
- package/dist/tool-window.cjs +8 -2
- package/dist/tool-window.cjs.map +1 -1
- package/dist/tool-window.d.cts +21 -3
- package/dist/tool-window.d.ts +21 -3
- package/dist/tool-window.js +8 -3
- package/dist/tool-window.js.map +1 -1
- package/package.json +2 -1
package/dist/app-store.cjs
CHANGED
|
@@ -22,16 +22,22 @@ function createAppStore(opts) {
|
|
|
22
22
|
// Migration: a settings file written before places were keyed by project has
|
|
23
23
|
// a single `place` belonging to `lastProject`. Carry it over rather than
|
|
24
24
|
// dropping it, or everyone's first launch after this update forgets where
|
|
25
|
-
// they were
|
|
25
|
+
// they were - but only where no keyed entry exists. The first cut of this
|
|
26
|
+
// merge put the legacy value LAST, so a stale singular beat every fresh
|
|
27
|
+
// per-project write on every launch, and (because the `...loaded` spread
|
|
28
|
+
// above kept the key alive through save) it did so for ever: writes landed
|
|
29
|
+
// correctly all session and the next start quietly reset them. Found from
|
|
30
|
+
// a host's user report; the key is retired just below.
|
|
26
31
|
places: {
|
|
27
|
-
...loaded.
|
|
28
|
-
...loaded.
|
|
32
|
+
...loaded.lastProject !== void 0 && loaded.place !== void 0 ? { [loaded.lastProject]: loaded.place } : {},
|
|
33
|
+
...loaded.places ?? {}
|
|
29
34
|
},
|
|
30
35
|
// The app's slice merges FIELD BY FIELD, so a key added in a new version
|
|
31
36
|
// arrives with its default rather than being absent for everybody who has
|
|
32
37
|
// run the app before.
|
|
33
38
|
app: { ...opts.defaults, ...loaded.app ?? {} }
|
|
34
39
|
};
|
|
40
|
+
delete state.place;
|
|
35
41
|
const save = () => {
|
|
36
42
|
try {
|
|
37
43
|
fs.mkdirSync(opts.dir, { recursive: true });
|
package/dist/app-store.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/app-store.ts"],"names":["join","readFileSync","mkdirSync","writeFileSync","renameSync"],"mappings":"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","file":"app-store.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were, which is the exact complaint the change is meant to fix.\n places: {\n ...(loaded.places ?? {}),\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/app-store.ts"],"names":["join","readFileSync","mkdirSync","writeFileSync","renameSync"],"mappings":"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","file":"app-store.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were - but only where no keyed entry exists. The first cut of this\n // merge put the legacy value LAST, so a stale singular beat every fresh\n // per-project write on every launch, and (because the `...loaded` spread\n // above kept the key alive through save) it did so for ever: writes landed\n // correctly all session and the next start quietly reset them. Found from\n // a host's user report; the key is retired just below.\n places: {\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n ...(loaded.places ?? {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n // Retire the legacy key: it rode in on `...loaded`, and a state that still\n // carries it re-runs the fold on every future load.\n delete (state as { place?: Place }).place;\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
package/dist/app-store.js
CHANGED
|
@@ -20,16 +20,22 @@ function createAppStore(opts) {
|
|
|
20
20
|
// Migration: a settings file written before places were keyed by project has
|
|
21
21
|
// a single `place` belonging to `lastProject`. Carry it over rather than
|
|
22
22
|
// dropping it, or everyone's first launch after this update forgets where
|
|
23
|
-
// they were
|
|
23
|
+
// they were - but only where no keyed entry exists. The first cut of this
|
|
24
|
+
// merge put the legacy value LAST, so a stale singular beat every fresh
|
|
25
|
+
// per-project write on every launch, and (because the `...loaded` spread
|
|
26
|
+
// above kept the key alive through save) it did so for ever: writes landed
|
|
27
|
+
// correctly all session and the next start quietly reset them. Found from
|
|
28
|
+
// a host's user report; the key is retired just below.
|
|
24
29
|
places: {
|
|
25
|
-
...loaded.
|
|
26
|
-
...loaded.
|
|
30
|
+
...loaded.lastProject !== void 0 && loaded.place !== void 0 ? { [loaded.lastProject]: loaded.place } : {},
|
|
31
|
+
...loaded.places ?? {}
|
|
27
32
|
},
|
|
28
33
|
// The app's slice merges FIELD BY FIELD, so a key added in a new version
|
|
29
34
|
// arrives with its default rather than being absent for everybody who has
|
|
30
35
|
// run the app before.
|
|
31
36
|
app: { ...opts.defaults, ...loaded.app ?? {} }
|
|
32
37
|
};
|
|
38
|
+
delete state.place;
|
|
33
39
|
const save = () => {
|
|
34
40
|
try {
|
|
35
41
|
mkdirSync(opts.dir, { recursive: true });
|
package/dist/app-store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/app-store.ts"],"names":[],"mappings":"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","file":"app-store.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were, which is the exact complaint the change is meant to fix.\n places: {\n ...(loaded.places ?? {}),\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/app-store.ts"],"names":[],"mappings":"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","file":"app-store.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were - but only where no keyed entry exists. The first cut of this\n // merge put the legacy value LAST, so a stale singular beat every fresh\n // per-project write on every launch, and (because the `...loaded` spread\n // above kept the key alive through save) it did so for ever: writes landed\n // correctly all session and the next start quietly reset them. Found from\n // a host's user report; the key is retired just below.\n places: {\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n ...(loaded.places ?? {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n // Retire the legacy key: it rode in on `...loaded`, and a state that still\n // carries it re-runs the fold on every future load.\n delete (state as { place?: Place }).place;\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
package/dist/tool-window.cjs
CHANGED
|
@@ -39,9 +39,14 @@ function centeredOnPrimary(size) {
|
|
|
39
39
|
function rescueToolWindow(w, def) {
|
|
40
40
|
if (!w || w.isDestroyed()) return;
|
|
41
41
|
if (w.isMinimized()) w.restore();
|
|
42
|
-
w.setAlwaysOnTop(true);
|
|
43
42
|
w.setBounds({ ...def, ...centeredOnPrimary(def) });
|
|
44
43
|
w.show();
|
|
44
|
+
w.moveTop();
|
|
45
|
+
}
|
|
46
|
+
function pinToolWindow(w, parent, on) {
|
|
47
|
+
if (!w || w.isDestroyed()) return;
|
|
48
|
+
w.setAlwaysOnTop(false);
|
|
49
|
+
w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);
|
|
45
50
|
}
|
|
46
51
|
function openToolWindow(existing, opts) {
|
|
47
52
|
if (existing && !existing.isDestroyed()) {
|
|
@@ -54,7 +59,6 @@ function openToolWindow(existing, opts) {
|
|
|
54
59
|
show: false,
|
|
55
60
|
title: opts.title,
|
|
56
61
|
...opts.frame !== void 0 ? { frame: opts.frame } : {},
|
|
57
|
-
...opts.alwaysOnTop !== void 0 ? { alwaysOnTop: opts.alwaysOnTop } : {},
|
|
58
62
|
webPreferences: {
|
|
59
63
|
contextIsolation: true,
|
|
60
64
|
nodeIntegration: false,
|
|
@@ -62,6 +66,7 @@ function openToolWindow(existing, opts) {
|
|
|
62
66
|
preload: opts.preload
|
|
63
67
|
}
|
|
64
68
|
});
|
|
69
|
+
if (opts.pinTo !== void 0) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);
|
|
65
70
|
if (opts.remember) rememberBounds(w, opts.remember);
|
|
66
71
|
w.once("ready-to-show", () => w.show());
|
|
67
72
|
if (opts.onClosed) w.on("closed", opts.onClosed);
|
|
@@ -72,6 +77,7 @@ function openToolWindow(existing, opts) {
|
|
|
72
77
|
|
|
73
78
|
exports.centeredOnPrimary = centeredOnPrimary;
|
|
74
79
|
exports.openToolWindow = openToolWindow;
|
|
80
|
+
exports.pinToolWindow = pinToolWindow;
|
|
75
81
|
exports.rememberBounds = rememberBounds;
|
|
76
82
|
exports.rescueToolWindow = rescueToolWindow;
|
|
77
83
|
exports.savedWindowRect = savedWindowRect;
|
package/dist/tool-window.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tool-window.ts"],"names":["screen","BrowserWindow","join"],"mappings":"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","file":"tool-window.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setAlwaysOnTop(true);\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** Floats over the editor (the pin); remembered by the host. */\n alwaysOnTop?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n ...(opts.alwaysOnTop !== undefined ? { alwaysOnTop: opts.alwaysOnTop } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/tool-window.ts"],"names":["screen","BrowserWindow","join"],"mappings":"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","file":"tool-window.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n // Raise within OUR stack. This used to setAlwaysOnTop(true), which on macOS\n // and Windows floats the window above every other APPLICATION - and stuck\n // that way, since nothing ever unset it.\n w.moveTop();\n}\n\n/**\n * The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the\n * world. `alwaysOnTop` was the first implementation and it floats the window\n * over every other application on macOS and Windows, so a pinned Board meant\n * nothing else on the machine could come to the front (a Storyletter user\n * report, 2026-08-25). A CHILD window is the semantic actually wanted on both\n * platforms: it stays over its parent and stacks normally against everything\n * else. Clearing any alwaysOnTop first also heals a window an older build\n * pinned the old way.\n */\nexport function pinToolWindow(\n w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean,\n): void {\n if (!w || w.isDestroyed()) return;\n w.setAlwaysOnTop(false);\n w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** The window to PIN ABOVE: the app's main window, resolved at open time (a\n * getter, because the main window can be recreated). With `pinned` true the\n * new tool window is parented to it, which keeps it above the editor and\n * stacks it normally against every other application - the semantic\n * `alwaysOnTop` (this option's late predecessor) got wrong on macOS and\n * Windows, where it floats over the whole machine. */\n pinTo?: () => BrowserWindow | undefined;\n /** Start pinned (the host remembers); flip later with `pinToolWindow`. */\n pinned?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.pinTo !== undefined) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
package/dist/tool-window.d.cts
CHANGED
|
@@ -35,6 +35,17 @@ declare function centeredOnPrimary(size: ToolWindowSize): {
|
|
|
35
35
|
* bounds itself (that's a store concern). No focus steal: helpers show
|
|
36
36
|
* behind the editor. */
|
|
37
37
|
declare function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void;
|
|
38
|
+
/**
|
|
39
|
+
* The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the
|
|
40
|
+
* world. `alwaysOnTop` was the first implementation and it floats the window
|
|
41
|
+
* over every other application on macOS and Windows, so a pinned Board meant
|
|
42
|
+
* nothing else on the machine could come to the front (a Storyletter user
|
|
43
|
+
* report, 2026-08-25). A CHILD window is the semantic actually wanted on both
|
|
44
|
+
* platforms: it stays over its parent and stacks normally against everything
|
|
45
|
+
* else. Clearing any alwaysOnTop first also heals a window an older build
|
|
46
|
+
* pinned the old way.
|
|
47
|
+
*/
|
|
48
|
+
declare function pinToolWindow(w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean): void;
|
|
38
49
|
interface ToolWindowOptions {
|
|
39
50
|
title: string;
|
|
40
51
|
/** Renderer entry, relative to rendererDir (e.g. "table.html", "search/index.html"). */
|
|
@@ -47,8 +58,15 @@ interface ToolWindowOptions {
|
|
|
47
58
|
min?: ToolWindowSize;
|
|
48
59
|
/** false = frameless: the renderer draws its own slim drag bar (.swin-head). */
|
|
49
60
|
frame?: boolean;
|
|
50
|
-
/**
|
|
51
|
-
|
|
61
|
+
/** The window to PIN ABOVE: the app's main window, resolved at open time (a
|
|
62
|
+
* getter, because the main window can be recreated). With `pinned` true the
|
|
63
|
+
* new tool window is parented to it, which keeps it above the editor and
|
|
64
|
+
* stacks it normally against every other application - the semantic
|
|
65
|
+
* `alwaysOnTop` (this option's late predecessor) got wrong on macOS and
|
|
66
|
+
* Windows, where it floats over the whole machine. */
|
|
67
|
+
pinTo?: () => BrowserWindow | undefined;
|
|
68
|
+
/** Start pinned (the host remembers); flip later with `pinToolWindow`. */
|
|
69
|
+
pinned?: boolean;
|
|
52
70
|
/** Wire debounced bounds persistence into the host's store slice. */
|
|
53
71
|
remember?: (bounds: ToolWindowBounds) => void;
|
|
54
72
|
/** Fired when THIS window closes (identity-guarded; a stale close never
|
|
@@ -60,4 +78,4 @@ interface ToolWindowOptions {
|
|
|
60
78
|
* existing window when there is one; otherwise creates, wires, and loads. */
|
|
61
79
|
declare function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow;
|
|
62
80
|
|
|
63
|
-
export { type ToolWindowBounds, type ToolWindowOptions, type ToolWindowSize, type ToolWindowState, centeredOnPrimary, openToolWindow, rememberBounds, rescueToolWindow, savedWindowRect };
|
|
81
|
+
export { type ToolWindowBounds, type ToolWindowOptions, type ToolWindowSize, type ToolWindowState, centeredOnPrimary, openToolWindow, pinToolWindow, rememberBounds, rescueToolWindow, savedWindowRect };
|
package/dist/tool-window.d.ts
CHANGED
|
@@ -35,6 +35,17 @@ declare function centeredOnPrimary(size: ToolWindowSize): {
|
|
|
35
35
|
* bounds itself (that's a store concern). No focus steal: helpers show
|
|
36
36
|
* behind the editor. */
|
|
37
37
|
declare function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void;
|
|
38
|
+
/**
|
|
39
|
+
* The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the
|
|
40
|
+
* world. `alwaysOnTop` was the first implementation and it floats the window
|
|
41
|
+
* over every other application on macOS and Windows, so a pinned Board meant
|
|
42
|
+
* nothing else on the machine could come to the front (a Storyletter user
|
|
43
|
+
* report, 2026-08-25). A CHILD window is the semantic actually wanted on both
|
|
44
|
+
* platforms: it stays over its parent and stacks normally against everything
|
|
45
|
+
* else. Clearing any alwaysOnTop first also heals a window an older build
|
|
46
|
+
* pinned the old way.
|
|
47
|
+
*/
|
|
48
|
+
declare function pinToolWindow(w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean): void;
|
|
38
49
|
interface ToolWindowOptions {
|
|
39
50
|
title: string;
|
|
40
51
|
/** Renderer entry, relative to rendererDir (e.g. "table.html", "search/index.html"). */
|
|
@@ -47,8 +58,15 @@ interface ToolWindowOptions {
|
|
|
47
58
|
min?: ToolWindowSize;
|
|
48
59
|
/** false = frameless: the renderer draws its own slim drag bar (.swin-head). */
|
|
49
60
|
frame?: boolean;
|
|
50
|
-
/**
|
|
51
|
-
|
|
61
|
+
/** The window to PIN ABOVE: the app's main window, resolved at open time (a
|
|
62
|
+
* getter, because the main window can be recreated). With `pinned` true the
|
|
63
|
+
* new tool window is parented to it, which keeps it above the editor and
|
|
64
|
+
* stacks it normally against every other application - the semantic
|
|
65
|
+
* `alwaysOnTop` (this option's late predecessor) got wrong on macOS and
|
|
66
|
+
* Windows, where it floats over the whole machine. */
|
|
67
|
+
pinTo?: () => BrowserWindow | undefined;
|
|
68
|
+
/** Start pinned (the host remembers); flip later with `pinToolWindow`. */
|
|
69
|
+
pinned?: boolean;
|
|
52
70
|
/** Wire debounced bounds persistence into the host's store slice. */
|
|
53
71
|
remember?: (bounds: ToolWindowBounds) => void;
|
|
54
72
|
/** Fired when THIS window closes (identity-guarded; a stale close never
|
|
@@ -60,4 +78,4 @@ interface ToolWindowOptions {
|
|
|
60
78
|
* existing window when there is one; otherwise creates, wires, and loads. */
|
|
61
79
|
declare function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow;
|
|
62
80
|
|
|
63
|
-
export { type ToolWindowBounds, type ToolWindowOptions, type ToolWindowSize, type ToolWindowState, centeredOnPrimary, openToolWindow, rememberBounds, rescueToolWindow, savedWindowRect };
|
|
81
|
+
export { type ToolWindowBounds, type ToolWindowOptions, type ToolWindowSize, type ToolWindowState, centeredOnPrimary, openToolWindow, pinToolWindow, rememberBounds, rescueToolWindow, savedWindowRect };
|
package/dist/tool-window.js
CHANGED
|
@@ -37,9 +37,14 @@ function centeredOnPrimary(size) {
|
|
|
37
37
|
function rescueToolWindow(w, def) {
|
|
38
38
|
if (!w || w.isDestroyed()) return;
|
|
39
39
|
if (w.isMinimized()) w.restore();
|
|
40
|
-
w.setAlwaysOnTop(true);
|
|
41
40
|
w.setBounds({ ...def, ...centeredOnPrimary(def) });
|
|
42
41
|
w.show();
|
|
42
|
+
w.moveTop();
|
|
43
|
+
}
|
|
44
|
+
function pinToolWindow(w, parent, on) {
|
|
45
|
+
if (!w || w.isDestroyed()) return;
|
|
46
|
+
w.setAlwaysOnTop(false);
|
|
47
|
+
w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);
|
|
43
48
|
}
|
|
44
49
|
function openToolWindow(existing, opts) {
|
|
45
50
|
if (existing && !existing.isDestroyed()) {
|
|
@@ -52,7 +57,6 @@ function openToolWindow(existing, opts) {
|
|
|
52
57
|
show: false,
|
|
53
58
|
title: opts.title,
|
|
54
59
|
...opts.frame !== void 0 ? { frame: opts.frame } : {},
|
|
55
|
-
...opts.alwaysOnTop !== void 0 ? { alwaysOnTop: opts.alwaysOnTop } : {},
|
|
56
60
|
webPreferences: {
|
|
57
61
|
contextIsolation: true,
|
|
58
62
|
nodeIntegration: false,
|
|
@@ -60,6 +64,7 @@ function openToolWindow(existing, opts) {
|
|
|
60
64
|
preload: opts.preload
|
|
61
65
|
}
|
|
62
66
|
});
|
|
67
|
+
if (opts.pinTo !== void 0) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);
|
|
63
68
|
if (opts.remember) rememberBounds(w, opts.remember);
|
|
64
69
|
w.once("ready-to-show", () => w.show());
|
|
65
70
|
if (opts.onClosed) w.on("closed", opts.onClosed);
|
|
@@ -68,6 +73,6 @@ function openToolWindow(existing, opts) {
|
|
|
68
73
|
return w;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
export { centeredOnPrimary, openToolWindow, rememberBounds, rescueToolWindow, savedWindowRect };
|
|
76
|
+
export { centeredOnPrimary, openToolWindow, pinToolWindow, rememberBounds, rescueToolWindow, savedWindowRect };
|
|
72
77
|
//# sourceMappingURL=tool-window.js.map
|
|
73
78
|
//# sourceMappingURL=tool-window.js.map
|
package/dist/tool-window.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tool-window.ts"],"names":[],"mappings":"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","file":"tool-window.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setAlwaysOnTop(true);\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** Floats over the editor (the pin); remembered by the host. */\n alwaysOnTop?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n ...(opts.alwaysOnTop !== undefined ? { alwaysOnTop: opts.alwaysOnTop } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/tool-window.ts"],"names":[],"mappings":"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","file":"tool-window.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n // Raise within OUR stack. This used to setAlwaysOnTop(true), which on macOS\n // and Windows floats the window above every other APPLICATION - and stuck\n // that way, since nothing ever unset it.\n w.moveTop();\n}\n\n/**\n * The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the\n * world. `alwaysOnTop` was the first implementation and it floats the window\n * over every other application on macOS and Windows, so a pinned Board meant\n * nothing else on the machine could come to the front (a Storyletter user\n * report, 2026-08-25). A CHILD window is the semantic actually wanted on both\n * platforms: it stays over its parent and stacks normally against everything\n * else. Clearing any alwaysOnTop first also heals a window an older build\n * pinned the old way.\n */\nexport function pinToolWindow(\n w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean,\n): void {\n if (!w || w.isDestroyed()) return;\n w.setAlwaysOnTop(false);\n w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** The window to PIN ABOVE: the app's main window, resolved at open time (a\n * getter, because the main window can be recreated). With `pinned` true the\n * new tool window is parented to it, which keeps it above the editor and\n * stacks it normally against every other application - the semantic\n * `alwaysOnTop` (this option's late predecessor) got wrong on macOS and\n * Windows, where it floats over the whole machine. */\n pinTo?: () => BrowserWindow | undefined;\n /** Start pinned (the host remembers); flip later with `pinToolWindow`. */\n pinned?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.pinTo !== undefined) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wildwinter/app-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "The shared shell for the editor family (Patterpad, Storylet Studio): the app-agnostic design-token grammar, the per-entity colour hash, and the vanilla-TS DOM primitive. Two palettes, one grammar - each app supplies its own palette/fonts over these tokens.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"access": "public"
|
|
133
133
|
},
|
|
134
134
|
"scripts": {
|
|
135
|
+
"prepare": "git config core.hooksPath scripts/git-hooks || true",
|
|
135
136
|
"build": "tsup",
|
|
136
137
|
"test": "vitest run",
|
|
137
138
|
"typecheck": "tsc --noEmit",
|