@skillre/dsh-plugin-sticky-wall 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.en.md +154 -0
- package/README.md +164 -2
- package/UNINSTALL.md +26 -0
- package/compatibility.json +83 -0
- package/cordis.patch.yml +10 -0
- package/docs/DETAIL.md +1 -0
- package/docs/DETAIL.zh.md +1 -0
- package/docs/PREVIEW.md +39 -0
- package/docs/preview.svg +170 -0
- package/docs/preview.zh.svg +170 -0
- package/icon.svg +15 -0
- package/lib/client.js +1694 -0
- package/lib/config.d.ts +22 -0
- package/lib/config.js +21 -0
- package/lib/index.d.ts +23 -0
- package/lib/index.js +184 -0
- package/lib/layout.d.ts +76 -0
- package/lib/layout.js +259 -0
- package/lib/store.d.ts +182 -0
- package/lib/store.js +452 -0
- package/lib/transport.d.ts +48 -0
- package/lib/transport.js +174 -0
- package/lib/types.d.ts +172 -0
- package/lib/types.js +64 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +116 -4
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration.
|
|
3
|
+
*
|
|
4
|
+
* Schemastery is the loader-facing schema: DSH resolves a row's `config`
|
|
5
|
+
* through it before `apply` runs, so every tunable value has a declared
|
|
6
|
+
* default and an out-of-range value fails loudly at mount time instead of
|
|
7
|
+
* corrupting a wall silently.
|
|
8
|
+
*/
|
|
9
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
10
|
+
/** Effective plugin configuration after schema resolution. */
|
|
11
|
+
export interface WallConfig {
|
|
12
|
+
/** Reject writes whose note count would exceed this cap. */
|
|
13
|
+
maxNotes: number;
|
|
14
|
+
/** Register the `sticky_wall` model tool so an agent can capture notes. */
|
|
15
|
+
enableAgentTool: boolean;
|
|
16
|
+
/** Skip the built-in welcome note on an empty wall. */
|
|
17
|
+
seedWelcomeNote: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** The schema DSH resolves a `skillre-sticky-wall` row's config through. */
|
|
20
|
+
export declare const Config: Schema<WallConfig>;
|
|
21
|
+
/** Resolve raw row config, keeping the declared defaults authoritative. */
|
|
22
|
+
export declare function resolveConfig(raw?: unknown): WallConfig;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration.
|
|
3
|
+
*
|
|
4
|
+
* Schemastery is the loader-facing schema: DSH resolves a row's `config`
|
|
5
|
+
* through it before `apply` runs, so every tunable value has a declared
|
|
6
|
+
* default and an out-of-range value fails loudly at mount time instead of
|
|
7
|
+
* corrupting a wall silently.
|
|
8
|
+
*/
|
|
9
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
10
|
+
/** The schema DSH resolves a `skillre-sticky-wall` row's config through. */
|
|
11
|
+
export const Config = Schema.object({
|
|
12
|
+
maxNotes: Schema.number().min(1).max(5000).step(1).default(400),
|
|
13
|
+
enableAgentTool: Schema.boolean().default(true),
|
|
14
|
+
seedWelcomeNote: Schema.boolean().default(true),
|
|
15
|
+
});
|
|
16
|
+
/** Resolve raw row config, keeping the declared defaults authoritative. */
|
|
17
|
+
export function resolveConfig(raw = {}) {
|
|
18
|
+
// Schemastery fills every declared default, so the resolved value always
|
|
19
|
+
// carries the full shape; the cast states that instead of re-declaring it.
|
|
20
|
+
return Config(raw);
|
|
21
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host entry of the sticky wall.
|
|
3
|
+
*
|
|
4
|
+
* Ownership split: this half owns durable notes (storage domain) and the
|
|
5
|
+
* Connection route the Client renders from; the Client half owns only view
|
|
6
|
+
* state. Everything registered here — the domain handle, the exact Fetch
|
|
7
|
+
* route, and the optional agent tool — belongs to this plugin's Fiber, so
|
|
8
|
+
* stopping or updating the row releases all three with no residue.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import { Config } from './config.js';
|
|
12
|
+
import type { WallConfig } from './config.js';
|
|
13
|
+
import { StickyWallStore } from './store.js';
|
|
14
|
+
export { Config, StickyWallStore };
|
|
15
|
+
export declare const name = "skillre-sticky-wall";
|
|
16
|
+
/**
|
|
17
|
+
* Required services. `storageDomain` provides the durable note table;
|
|
18
|
+
* `connection` provides the authenticated Client route. Both are declared hard
|
|
19
|
+
* dependencies so a profile missing either fails loudly instead of mounting a
|
|
20
|
+
* wall that can never persist or be reached.
|
|
21
|
+
*/
|
|
22
|
+
export declare const inject: string[];
|
|
23
|
+
export declare function apply(ctx: Context, rawConfig?: Partial<WallConfig>): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host entry of the sticky wall.
|
|
3
|
+
*
|
|
4
|
+
* Ownership split: this half owns durable notes (storage domain) and the
|
|
5
|
+
* Connection route the Client renders from; the Client half owns only view
|
|
6
|
+
* state. Everything registered here — the domain handle, the exact Fetch
|
|
7
|
+
* route, and the optional agent tool — belongs to this plugin's Fiber, so
|
|
8
|
+
* stopping or updating the row releases all three with no residue.
|
|
9
|
+
*/
|
|
10
|
+
import { Config, resolveConfig } from './config.js';
|
|
11
|
+
import { StickyWallStore } from './store.js';
|
|
12
|
+
import { createExecuteRoute } from './transport.js';
|
|
13
|
+
import { LIMITS } from './types.js';
|
|
14
|
+
export { Config, StickyWallStore };
|
|
15
|
+
export const name = 'skillre-sticky-wall';
|
|
16
|
+
/**
|
|
17
|
+
* Required services. `storageDomain` provides the durable note table;
|
|
18
|
+
* `connection` provides the authenticated Client route. Both are declared hard
|
|
19
|
+
* dependencies so a profile missing either fails loudly instead of mounting a
|
|
20
|
+
* wall that can never persist or be reached.
|
|
21
|
+
*/
|
|
22
|
+
export const inject = ['storageDomain', 'connection'];
|
|
23
|
+
/** Read an optional Cordis service without declaring it required. */
|
|
24
|
+
function service(ctx, key) {
|
|
25
|
+
return ctx.get(key);
|
|
26
|
+
}
|
|
27
|
+
/** Model-facing text for one tool result; short, deterministic and readable. */
|
|
28
|
+
function renderWall(value) {
|
|
29
|
+
const data = (value ?? {});
|
|
30
|
+
const action = typeof data.action === 'string' ? data.action : 'result';
|
|
31
|
+
if (action === 'list') {
|
|
32
|
+
const notes = Array.isArray(data.notes) ? data.notes : [];
|
|
33
|
+
if (notes.length === 0)
|
|
34
|
+
return 'The note wall is empty.';
|
|
35
|
+
const lines = notes.map((note) => {
|
|
36
|
+
const pin = note.pinned ? '📌 ' : '';
|
|
37
|
+
const tags = note.tags.length > 0 ? ` [${note.tags.join(', ')}]` : '';
|
|
38
|
+
const archived = note.archived ? ' (archived)' : '';
|
|
39
|
+
const heading = note.title ? `${note.title}: ` : '';
|
|
40
|
+
return `- ${pin}${note.id}${archived}${tags} ${heading}${note.body.replace(/\s+/g, ' ').slice(0, 140)}`;
|
|
41
|
+
});
|
|
42
|
+
return [`${String(data.total ?? notes.length)} notes (${String(data.archived ?? 0)} archived):`, ...lines].join('\n');
|
|
43
|
+
}
|
|
44
|
+
if (action === 'arrange') {
|
|
45
|
+
const notes = Array.isArray(data.changed) ? data.changed : [];
|
|
46
|
+
return notes.length === 0 ? 'The wall was already tidy.' : `Tidied ${notes.length} note${notes.length === 1 ? '' : 's'} into the shelf arrangement.`;
|
|
47
|
+
}
|
|
48
|
+
if (action === 'delete' || action === 'clear-archived') {
|
|
49
|
+
const removed = Array.isArray(data.removed) ? data.removed.length : 0;
|
|
50
|
+
return `Removed ${removed} note${removed === 1 ? '' : 's'}.`;
|
|
51
|
+
}
|
|
52
|
+
if (action === 'archive') {
|
|
53
|
+
const notes = Array.isArray(data.notes) ? data.notes : [];
|
|
54
|
+
return notes.length === 0 ? 'No note changed.' : notes.map((note) => `${note.id} ${note.archived ? 'archived' : 'restored'}`).join('\n');
|
|
55
|
+
}
|
|
56
|
+
const note = data.note;
|
|
57
|
+
if (note)
|
|
58
|
+
return `${action === 'create' ? 'Created' : 'Updated'} note ${note.id}${note.title ? ` (${note.title})` : ''}: ${note.body.replace(/\s+/g, ' ').slice(0, 140)}`;
|
|
59
|
+
return 'Done.';
|
|
60
|
+
}
|
|
61
|
+
/** Register the model-facing capture tool when the registry is present. */
|
|
62
|
+
function registerAgentTool(ctx, ready) {
|
|
63
|
+
const tools = service(ctx, 'tools');
|
|
64
|
+
if (!tools)
|
|
65
|
+
return;
|
|
66
|
+
ctx.effect(() => {
|
|
67
|
+
const disposer = tools.register({
|
|
68
|
+
name: 'sticky_wall',
|
|
69
|
+
description: "Capture and manage sticky notes on the user's DSH note wall (便签墙): quick inspiration, temporary tasks and short reminders. Use it when the user asks to note something down, list their notes, update or pin one, or clean up archived notes.",
|
|
70
|
+
parameters: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
required: ['action'],
|
|
74
|
+
properties: {
|
|
75
|
+
action: { type: 'string', enum: ['list', 'create', 'update', 'archive', 'delete', 'clear-archived', 'arrange'] },
|
|
76
|
+
id: { type: 'string', description: 'Exact note id; required by update.' },
|
|
77
|
+
ids: { type: 'array', items: { type: 'string' }, description: 'Note ids; required by archive and delete (1–200 entries).' },
|
|
78
|
+
body: { type: 'string', description: `The note text (1–${LIMITS.body} characters). Required by create; optional patch field for update.` },
|
|
79
|
+
title: { type: 'string', description: `Optional short heading (max ${LIMITS.title} characters).` },
|
|
80
|
+
tags: { type: 'array', items: { type: 'string' }, description: `Optional lowercase tags (max ${LIMITS.tags}).` },
|
|
81
|
+
color: { type: 'string', enum: ['yellow', 'pink', 'green', 'blue', 'purple', 'orange'], description: 'Optional pastel note color.' },
|
|
82
|
+
pinned: { type: 'boolean', description: 'Pin the note to the top of the wall.' },
|
|
83
|
+
archived: { type: 'boolean', description: 'Archive (true) or restore (false) one note on update.' },
|
|
84
|
+
includeArchived: { type: 'boolean', description: 'list only: include archived notes.' },
|
|
85
|
+
x: { type: 'number', description: 'Free-canvas horizontal position of the card, in canvas pixels. Optional on create.' },
|
|
86
|
+
y: { type: 'number', description: 'Free-canvas vertical position of the card, in canvas pixels. Optional on create.' },
|
|
87
|
+
limit: { type: 'integer', description: 'list only: how many notes to return (1–50, default 20).' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
timeoutMs: 20_000,
|
|
91
|
+
isConcurrencySafe(args) {
|
|
92
|
+
return typeof args === 'object' && args !== null && args.action === 'list';
|
|
93
|
+
},
|
|
94
|
+
output: {
|
|
95
|
+
schema: { type: 'object', additionalProperties: true },
|
|
96
|
+
render(_args, value) {
|
|
97
|
+
return [{ type: 'text', text: renderWall(value) }];
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
async execute(raw, execution) {
|
|
101
|
+
execution.signal.throwIfAborted();
|
|
102
|
+
const store = await ready;
|
|
103
|
+
const args = (raw ?? {});
|
|
104
|
+
switch (args.action) {
|
|
105
|
+
case 'list':
|
|
106
|
+
return { action: 'list', ...store.summary(typeof args.limit === 'number' ? args.limit : 20) };
|
|
107
|
+
case 'create':
|
|
108
|
+
return {
|
|
109
|
+
action: 'create',
|
|
110
|
+
note: await store.create({
|
|
111
|
+
body: String(args.body ?? ''),
|
|
112
|
+
...(typeof args.title === 'string' ? { title: args.title } : {}),
|
|
113
|
+
...(Array.isArray(args.tags) ? { tags: args.tags.map(String) } : {}),
|
|
114
|
+
...(typeof args.color === 'string' ? { color: args.color } : {}),
|
|
115
|
+
...(typeof args.pinned === 'boolean' ? { pinned: args.pinned } : {}),
|
|
116
|
+
...(typeof args.x === 'number' && typeof args.y === 'number'
|
|
117
|
+
? { layout: { x: args.x, y: args.y, rotate: 0, width: 'md' } }
|
|
118
|
+
: {}),
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
case 'update':
|
|
122
|
+
return {
|
|
123
|
+
action: 'update',
|
|
124
|
+
note: await store.update(String(args.id ?? ''), {
|
|
125
|
+
...(typeof args.body === 'string' ? { body: args.body } : {}),
|
|
126
|
+
...(typeof args.title === 'string' ? { title: args.title } : {}),
|
|
127
|
+
...(Array.isArray(args.tags) ? { tags: args.tags.map(String) } : {}),
|
|
128
|
+
...(typeof args.color === 'string' ? { color: args.color } : {}),
|
|
129
|
+
...(typeof args.pinned === 'boolean' ? { pinned: args.pinned } : {}),
|
|
130
|
+
...(typeof args.archived === 'boolean' ? { archived: args.archived } : {}),
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
case 'archive':
|
|
134
|
+
return { action: 'archive', notes: await store.setArchived(args.ids ?? [], args.archived === true) };
|
|
135
|
+
case 'delete':
|
|
136
|
+
return { action: 'delete', removed: await store.remove(args.ids ?? []) };
|
|
137
|
+
case 'clear-archived':
|
|
138
|
+
return { action: 'clear-archived', removed: await store.clearArchived() };
|
|
139
|
+
case 'arrange':
|
|
140
|
+
return { action: 'arrange', changed: await store.arrange() };
|
|
141
|
+
default:
|
|
142
|
+
throw new Error(`Unknown sticky_wall action ${JSON.stringify(args.action)}.`);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return disposer;
|
|
147
|
+
}, 'skillre-sticky-wall agent tool');
|
|
148
|
+
}
|
|
149
|
+
export function apply(ctx, rawConfig = {}) {
|
|
150
|
+
const config = resolveConfig(rawConfig);
|
|
151
|
+
const connection = service(ctx, 'connection');
|
|
152
|
+
if (!connection?.fetch?.register)
|
|
153
|
+
throw new Error('Sticky wall requires the current DSH Connection exact Fetch-route registry.');
|
|
154
|
+
const facility = service(ctx, 'storageDomain');
|
|
155
|
+
if (!facility?.open)
|
|
156
|
+
throw new Error('Sticky wall requires the DSH storageDomain facility.');
|
|
157
|
+
const ready = StickyWallStore.open((spec) => facility.open(spec), {
|
|
158
|
+
maxNotes: config.maxNotes,
|
|
159
|
+
seedWelcomeNote: config.seedWelcomeNote,
|
|
160
|
+
});
|
|
161
|
+
ready.catch((error) => {
|
|
162
|
+
// The route and the tool both await `ready`, so the failure also reaches the
|
|
163
|
+
// first caller; log it once so it is never silent in the Host log.
|
|
164
|
+
ctx.logger?.error?.(`sticky-wall: storage domain failed to open: ${error instanceof Error ? error.message : String(error)}`);
|
|
165
|
+
});
|
|
166
|
+
ctx.effect(() => () => {
|
|
167
|
+
void ready.then((store) => store.close()).catch(() => { });
|
|
168
|
+
}, 'skillre-sticky-wall storage lifetime');
|
|
169
|
+
ctx.effect(() => connection.fetch.register(createExecuteRoute(async () => {
|
|
170
|
+
const store = await ready;
|
|
171
|
+
return {
|
|
172
|
+
list: (includeArchived, sort) => store.list(includeArchived, sort),
|
|
173
|
+
create: (input) => store.create(input),
|
|
174
|
+
update: (id, patch) => store.update(id, patch),
|
|
175
|
+
setArchived: (ids, archived) => store.setArchived(ids, archived),
|
|
176
|
+
remove: (ids) => store.remove(ids),
|
|
177
|
+
clearArchived: () => store.clearArchived(),
|
|
178
|
+
move: (id, layout) => store.move(id, layout),
|
|
179
|
+
arrange: () => store.arrange(),
|
|
180
|
+
};
|
|
181
|
+
})), 'skillre-sticky-wall client route');
|
|
182
|
+
if (config.enableAgentTool)
|
|
183
|
+
registerAgentTool(ctx, ready);
|
|
184
|
+
}
|
package/lib/layout.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-canvas geometry shared by the Host and the Client halves.
|
|
3
|
+
*
|
|
4
|
+
* The wall is a canvas, not a grid: every card owns a world position, a
|
|
5
|
+
* rotation and a width preset. Two operations produce those numbers:
|
|
6
|
+
*
|
|
7
|
+
* - {@link arrangeLayouts} is the Host-side "tidy" pass. It is deterministic:
|
|
8
|
+
* the same notes always produce the same wall, so a user who presses tidy
|
|
9
|
+
* twice does not get a reshuffled board, and a test can assert the exact
|
|
10
|
+
* packing without a browser.
|
|
11
|
+
* - {@link previewLayout} is the Client-side fallback for a note that was never
|
|
12
|
+
* placed (a wall written before the canvas existed, or a note created by the
|
|
13
|
+
* agent tool). It derives a position from the note's own id and its index in
|
|
14
|
+
* the stable order, so the first render is already a scattered wall instead
|
|
15
|
+
* of one tall stack, and it never writes to the Host.
|
|
16
|
+
*
|
|
17
|
+
* Both halves import this module, so the geometry never drifts between the
|
|
18
|
+
* value the Host persists and the value the Client previews.
|
|
19
|
+
*/
|
|
20
|
+
import { type Note, type NoteLayout, type NoteWidth } from './types.js';
|
|
21
|
+
/** Clamp one layout into the declared world bounds. */
|
|
22
|
+
export declare function clampLayout(layout: NoteLayout): NoteLayout;
|
|
23
|
+
/** True when two layouts describe the same position (rounded comparison). */
|
|
24
|
+
export declare function sameLayout(left: NoteLayout | null, right: NoteLayout | null): boolean;
|
|
25
|
+
/** Pick the width preset a never-placed note gets; weighted toward the middle. */
|
|
26
|
+
export declare function previewWidth(id: string): NoteWidth;
|
|
27
|
+
/** Hard height budget per width preset, in pixels at zoom 1. */
|
|
28
|
+
export declare const MAX_CARD_HEIGHT: Readonly<Record<NoteWidth, number>>;
|
|
29
|
+
/**
|
|
30
|
+
* Estimate a card's rendered height from its content.
|
|
31
|
+
*
|
|
32
|
+
* The tidy pass runs on the Host, which cannot measure text, so it estimates
|
|
33
|
+
* from the column budget of the chosen width. The estimate is deliberately
|
|
34
|
+
* conservative and capped by {@link MAX_CARD_HEIGHT}, and the Client applies
|
|
35
|
+
* that same cap as a CSS max-height: the estimate and the rendered box therefore
|
|
36
|
+
* agree, which is what keeps a tidied wall free of overlapping cards.
|
|
37
|
+
*/
|
|
38
|
+
export declare function estimateHeight(note: Note, width: NoteWidth): number;
|
|
39
|
+
/** The effective layout of one note: the stored one, or a derived preview. */
|
|
40
|
+
export declare function layoutOf(note: Note, index: number): NoteLayout;
|
|
41
|
+
/**
|
|
42
|
+
* Derived position for a note the user never placed.
|
|
43
|
+
*
|
|
44
|
+
* Index-based so two never-placed notes cannot collide, hash-jittered so the
|
|
45
|
+
* result still looks scattered rather than gridded.
|
|
46
|
+
*/
|
|
47
|
+
export declare function previewLayout(note: Note, index: number): NoteLayout;
|
|
48
|
+
/**
|
|
49
|
+
* The axis-aligned bounding box of a rotated card.
|
|
50
|
+
*
|
|
51
|
+
* A tidied card is tilted up to {@link TIDY_TILT} degrees, which widens its
|
|
52
|
+
* footprint by `h * sin(t)`. Packing by the unrotated width therefore let
|
|
53
|
+
* neighbouring cards clip each other's corners, so the packer advances by the
|
|
54
|
+
* rotated box and the gap covers the rest.
|
|
55
|
+
*/
|
|
56
|
+
export declare function rotatedExtent(width: number, height: number, rotate: number): {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Deterministic tidy arrangement for the whole wall.
|
|
62
|
+
*
|
|
63
|
+
* Shelf packing: cards flow left to right with a hash jitter and a small tilt,
|
|
64
|
+
* wrapping to a new shelf when the row is full. The shelf width adapts to the
|
|
65
|
+
* wall: a large wall gets wider shelves so the arrangement stays roughly square
|
|
66
|
+
* instead of growing into a single column kilometres tall. The output is shifted
|
|
67
|
+
* so the top-left card starts near the origin, which keeps the canvas' scroll
|
|
68
|
+
* extent predictable instead of starting inside negative space.
|
|
69
|
+
*
|
|
70
|
+
* Returns a map from note id to its new layout, for every note that needs to
|
|
71
|
+
* move. Notes already at exactly that layout are omitted, so pressing tidy on
|
|
72
|
+
* an already-tidy wall writes nothing.
|
|
73
|
+
*/
|
|
74
|
+
export declare function arrangeLayouts(notes: readonly Note[]): Map<string, NoteLayout>;
|
|
75
|
+
/** Normalize a submitted width preset, defaulting to the middle size. */
|
|
76
|
+
export declare function normalizeWidth(raw: unknown): NoteWidth;
|
package/lib/layout.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-canvas geometry shared by the Host and the Client halves.
|
|
3
|
+
*
|
|
4
|
+
* The wall is a canvas, not a grid: every card owns a world position, a
|
|
5
|
+
* rotation and a width preset. Two operations produce those numbers:
|
|
6
|
+
*
|
|
7
|
+
* - {@link arrangeLayouts} is the Host-side "tidy" pass. It is deterministic:
|
|
8
|
+
* the same notes always produce the same wall, so a user who presses tidy
|
|
9
|
+
* twice does not get a reshuffled board, and a test can assert the exact
|
|
10
|
+
* packing without a browser.
|
|
11
|
+
* - {@link previewLayout} is the Client-side fallback for a note that was never
|
|
12
|
+
* placed (a wall written before the canvas existed, or a note created by the
|
|
13
|
+
* agent tool). It derives a position from the note's own id and its index in
|
|
14
|
+
* the stable order, so the first render is already a scattered wall instead
|
|
15
|
+
* of one tall stack, and it never writes to the Host.
|
|
16
|
+
*
|
|
17
|
+
* Both halves import this module, so the geometry never drifts between the
|
|
18
|
+
* value the Host persists and the value the Client previews.
|
|
19
|
+
*/
|
|
20
|
+
import { LAYOUT_LIMITS, WIDTHS, WIDTH_PX } from './types.js';
|
|
21
|
+
/** Minimum horizontal breathing room between two cards in the tidy packing. */
|
|
22
|
+
const SHELF_GAP_X = 26;
|
|
23
|
+
/** Minimum vertical breathing room between two shelves in the tidy packing. */
|
|
24
|
+
const SHELF_GAP_Y = 30;
|
|
25
|
+
/** Narrowest tidy shelf; the packer widens it for very large walls. */
|
|
26
|
+
const SHELF_WIDTH = 1180;
|
|
27
|
+
/** Preview grid cell, generous enough that scattered cards rarely overlap. */
|
|
28
|
+
const PREVIEW_CELL_W = 300;
|
|
29
|
+
const PREVIEW_CELL_H = 232;
|
|
30
|
+
/** Preview columns; four cells read as a wall on a typical centre column. */
|
|
31
|
+
const PREVIEW_COLUMNS = 4;
|
|
32
|
+
/** Maximum rotation the tidy pass adds, in degrees. */
|
|
33
|
+
const TIDY_TILT = 3.4;
|
|
34
|
+
/** Maximum rotation the preview fallback adds, in degrees. */
|
|
35
|
+
const PREVIEW_TILT = 4;
|
|
36
|
+
/** Deterministic 32-bit FNV-1a hash; no crypto, identical on both halves. */
|
|
37
|
+
function hash(text) {
|
|
38
|
+
let value = 0x811c9dc5;
|
|
39
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
40
|
+
value ^= text.charCodeAt(index);
|
|
41
|
+
value = Math.imul(value, 0x01000193) >>> 0;
|
|
42
|
+
}
|
|
43
|
+
return value >>> 0;
|
|
44
|
+
}
|
|
45
|
+
/** Deterministic unit float in `[0, 1)` derived from a string. */
|
|
46
|
+
function unit(text) {
|
|
47
|
+
return hash(text) / 0x100000000;
|
|
48
|
+
}
|
|
49
|
+
/** Round to one decimal so stored layouts stay small and diffable. */
|
|
50
|
+
function round(value) {
|
|
51
|
+
return Math.round(value * 10) / 10;
|
|
52
|
+
}
|
|
53
|
+
/** Clamp one layout into the declared world bounds. */
|
|
54
|
+
export function clampLayout(layout) {
|
|
55
|
+
const coord = LAYOUT_LIMITS.coord;
|
|
56
|
+
const tilt = LAYOUT_LIMITS.rotate;
|
|
57
|
+
return {
|
|
58
|
+
x: round(Math.min(coord, Math.max(-coord, Number(layout.x) || 0))),
|
|
59
|
+
y: round(Math.min(coord, Math.max(-coord, Number(layout.y) || 0))),
|
|
60
|
+
rotate: round(Math.min(tilt, Math.max(-tilt, Number(layout.rotate) || 0))),
|
|
61
|
+
width: layout.width,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** True when two layouts describe the same position (rounded comparison). */
|
|
65
|
+
export function sameLayout(left, right) {
|
|
66
|
+
if (left === null || right === null)
|
|
67
|
+
return left === right;
|
|
68
|
+
return (round(left.x) === round(right.x) &&
|
|
69
|
+
round(left.y) === round(right.y) &&
|
|
70
|
+
round(left.rotate) === round(right.rotate) &&
|
|
71
|
+
left.width === right.width);
|
|
72
|
+
}
|
|
73
|
+
/** Pick the width preset a never-placed note gets; weighted toward the middle. */
|
|
74
|
+
export function previewWidth(id) {
|
|
75
|
+
const roll = unit(`${id}:w`);
|
|
76
|
+
if (roll < 0.42)
|
|
77
|
+
return 'md';
|
|
78
|
+
if (roll < 0.74)
|
|
79
|
+
return 'sm';
|
|
80
|
+
return 'lg';
|
|
81
|
+
}
|
|
82
|
+
/** Hard height budget per width preset, in pixels at zoom 1. */
|
|
83
|
+
export const MAX_CARD_HEIGHT = Object.freeze({
|
|
84
|
+
sm: 236,
|
|
85
|
+
md: 288,
|
|
86
|
+
lg: 336,
|
|
87
|
+
});
|
|
88
|
+
/**
|
|
89
|
+
* Count text columns rather than characters.
|
|
90
|
+
*
|
|
91
|
+
* A CJK glyph occupies roughly two columns of a monospace-ish advance, and a
|
|
92
|
+
* wall written in Chinese is the common case for this plugin. Counting code
|
|
93
|
+
* points made every Chinese note look half as tall as it renders, which let the
|
|
94
|
+
* tidy pass pack shelves on top of each other.
|
|
95
|
+
*/
|
|
96
|
+
function columns(text) {
|
|
97
|
+
let total = 0;
|
|
98
|
+
for (const char of text) {
|
|
99
|
+
const code = char.codePointAt(0) ?? 0;
|
|
100
|
+
total += code >= 0x1100 && (code <= 0x115f || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)) ? 2 : 1;
|
|
101
|
+
}
|
|
102
|
+
return total;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Estimate a card's rendered height from its content.
|
|
106
|
+
*
|
|
107
|
+
* The tidy pass runs on the Host, which cannot measure text, so it estimates
|
|
108
|
+
* from the column budget of the chosen width. The estimate is deliberately
|
|
109
|
+
* conservative and capped by {@link MAX_CARD_HEIGHT}, and the Client applies
|
|
110
|
+
* that same cap as a CSS max-height: the estimate and the rendered box therefore
|
|
111
|
+
* agree, which is what keeps a tidied wall free of overlapping cards.
|
|
112
|
+
*/
|
|
113
|
+
export function estimateHeight(note, width) {
|
|
114
|
+
const columnsPerLine = columnsPerLineFor(width);
|
|
115
|
+
let lines = 0;
|
|
116
|
+
for (const paragraph of note.body.split('\n')) {
|
|
117
|
+
if (paragraph.trim() === '') {
|
|
118
|
+
lines += 0.4;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
lines += Math.max(1, Math.ceil(columns(paragraph.replace(/^[#>*\-+\d. ]+/u, '')) / columnsPerLine));
|
|
122
|
+
}
|
|
123
|
+
// The title wraps like any other text, and it is capped at LIMITS.title (120)
|
|
124
|
+
// characters — which is a dozen lines at the small preset, not the flat 1.4
|
|
125
|
+
// this used to charge. Under-counting it was a real overlap bug: a 120-char
|
|
126
|
+
// CJK title estimated at 104px while rendering at ~244px, below the CSS cap,
|
|
127
|
+
// so the cap could not mask it.
|
|
128
|
+
if (note.title !== '') {
|
|
129
|
+
// The title is one weight step heavier and has no paragraph spacing, so it
|
|
130
|
+
// fits slightly fewer columns than the body at the same width.
|
|
131
|
+
const titleColumns = columnsPerLineFor(width, 0.92);
|
|
132
|
+
lines += Math.max(1, Math.ceil(columns(note.title) / titleColumns));
|
|
133
|
+
}
|
|
134
|
+
// Tag pills wrap, and each pill costs its label plus padding and gap.
|
|
135
|
+
if (note.tags.length > 0) {
|
|
136
|
+
let pillColumns = 0;
|
|
137
|
+
for (const tag of note.tags)
|
|
138
|
+
pillColumns += columns(tag) + 4;
|
|
139
|
+
lines += Math.max(1, Math.ceil(pillColumns / columnsPerLine));
|
|
140
|
+
}
|
|
141
|
+
const clamped = Math.min(16, Math.max(2, lines));
|
|
142
|
+
const estimated = Math.round(58 + clamped * 19);
|
|
143
|
+
return Math.min(estimated, MAX_CARD_HEIGHT[width]);
|
|
144
|
+
}
|
|
145
|
+
/** Columns of text that fit on one line of a card at the given width. */
|
|
146
|
+
function columnsPerLineFor(width, density = 1) {
|
|
147
|
+
// 7.1px per column at the card's 13px type size, minus the horizontal padding.
|
|
148
|
+
return Math.max(8, Math.floor(((WIDTH_PX[width] - 26) / 7.1) * density));
|
|
149
|
+
}
|
|
150
|
+
/** The effective layout of one note: the stored one, or a derived preview. */
|
|
151
|
+
export function layoutOf(note, index) {
|
|
152
|
+
return note.layout ?? previewLayout(note, index);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Derived position for a note the user never placed.
|
|
156
|
+
*
|
|
157
|
+
* Index-based so two never-placed notes cannot collide, hash-jittered so the
|
|
158
|
+
* result still looks scattered rather than gridded.
|
|
159
|
+
*/
|
|
160
|
+
export function previewLayout(note, index) {
|
|
161
|
+
const column = index % PREVIEW_COLUMNS;
|
|
162
|
+
const row = Math.floor(index / PREVIEW_COLUMNS);
|
|
163
|
+
const width = previewWidth(note.id);
|
|
164
|
+
return clampLayout({
|
|
165
|
+
x: column * PREVIEW_CELL_W + unit(`${note.id}:px`) * 42,
|
|
166
|
+
y: row * PREVIEW_CELL_H + unit(`${note.id}:py`) * 34,
|
|
167
|
+
rotate: (unit(`${note.id}:pr`) - 0.5) * 2 * PREVIEW_TILT,
|
|
168
|
+
width,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The axis-aligned bounding box of a rotated card.
|
|
173
|
+
*
|
|
174
|
+
* A tidied card is tilted up to {@link TIDY_TILT} degrees, which widens its
|
|
175
|
+
* footprint by `h * sin(t)`. Packing by the unrotated width therefore let
|
|
176
|
+
* neighbouring cards clip each other's corners, so the packer advances by the
|
|
177
|
+
* rotated box and the gap covers the rest.
|
|
178
|
+
*/
|
|
179
|
+
export function rotatedExtent(width, height, rotate) {
|
|
180
|
+
const radians = (Math.abs(rotate) * Math.PI) / 180;
|
|
181
|
+
const cos = Math.cos(radians);
|
|
182
|
+
const sin = Math.sin(radians);
|
|
183
|
+
return { width: width * cos + height * sin, height: height * cos + width * sin };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Deterministic tidy arrangement for the whole wall.
|
|
187
|
+
*
|
|
188
|
+
* Shelf packing: cards flow left to right with a hash jitter and a small tilt,
|
|
189
|
+
* wrapping to a new shelf when the row is full. The shelf width adapts to the
|
|
190
|
+
* wall: a large wall gets wider shelves so the arrangement stays roughly square
|
|
191
|
+
* instead of growing into a single column kilometres tall. The output is shifted
|
|
192
|
+
* so the top-left card starts near the origin, which keeps the canvas' scroll
|
|
193
|
+
* extent predictable instead of starting inside negative space.
|
|
194
|
+
*
|
|
195
|
+
* Returns a map from note id to its new layout, for every note that needs to
|
|
196
|
+
* move. Notes already at exactly that layout are omitted, so pressing tidy on
|
|
197
|
+
* an already-tidy wall writes nothing.
|
|
198
|
+
*/
|
|
199
|
+
export function arrangeLayouts(notes) {
|
|
200
|
+
const placed = new Map();
|
|
201
|
+
if (notes.length === 0)
|
|
202
|
+
return placed;
|
|
203
|
+
// Decide each card's rotation and box up front: the shelf width and the
|
|
204
|
+
// advancement both depend on the rotated extent, and clamping has to happen
|
|
205
|
+
// before anything is measured.
|
|
206
|
+
const items = notes.map((note) => {
|
|
207
|
+
const width = note.layout?.width ?? previewWidth(note.id);
|
|
208
|
+
const cardWidth = WIDTH_PX[width];
|
|
209
|
+
const cardHeight = estimateHeight(note, width);
|
|
210
|
+
const rotate = (unit(`${note.id}:ar`) - 0.5) * 2 * TIDY_TILT;
|
|
211
|
+
const extent = rotatedExtent(cardWidth, cardHeight, rotate);
|
|
212
|
+
return { note, width, cardWidth, cardHeight, rotate, extent };
|
|
213
|
+
});
|
|
214
|
+
// Grow the shelf so a large wall stays square-ish. The bound is the world
|
|
215
|
+
// coordinate limit: an arrangement that would exceed it is a bug, not
|
|
216
|
+
// something to silently clamp (clamping pins every card to the same edge and
|
|
217
|
+
// produces massive overlap).
|
|
218
|
+
const totalArea = items.reduce((sum, item) => sum + item.extent.width * item.extent.height, 0);
|
|
219
|
+
const targetWidth = Math.max(SHELF_WIDTH, Math.ceil(Math.sqrt(totalArea) * 1.25));
|
|
220
|
+
const shelfWidth = Math.min(targetWidth, LAYOUT_LIMITS.coord);
|
|
221
|
+
let cursorX = 0;
|
|
222
|
+
let shelfY = 0;
|
|
223
|
+
let shelfHeight = 0;
|
|
224
|
+
let minX = 0;
|
|
225
|
+
let minY = 0;
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
if (cursorX > 0 && cursorX + item.extent.width > shelfWidth) {
|
|
228
|
+
cursorX = 0;
|
|
229
|
+
shelfY += shelfHeight + SHELF_GAP_Y;
|
|
230
|
+
shelfHeight = 0;
|
|
231
|
+
}
|
|
232
|
+
// The jitter is bounded so it can never close the inter-card gap: the gap
|
|
233
|
+
// budget is SHELF_GAP_X plus both cards' rotation expansion, and the jitter
|
|
234
|
+
// spends at most 5px of it.
|
|
235
|
+
const layout = clampLayout({
|
|
236
|
+
x: cursorX + (unit(`${item.note.id}:ax`) - 0.5) * 6,
|
|
237
|
+
y: shelfY + (unit(`${item.note.id}:ay`) - 0.5) * 8,
|
|
238
|
+
rotate: item.rotate,
|
|
239
|
+
width: item.width,
|
|
240
|
+
});
|
|
241
|
+
minX = Math.min(minX, layout.x);
|
|
242
|
+
minY = Math.min(minY, layout.y);
|
|
243
|
+
placed.set(item.note.id, layout);
|
|
244
|
+
cursorX += item.extent.width + SHELF_GAP_X;
|
|
245
|
+
shelfHeight = Math.max(shelfHeight, item.extent.height);
|
|
246
|
+
}
|
|
247
|
+
if (minX === 0 && minY === 0)
|
|
248
|
+
return placed;
|
|
249
|
+
for (const [id, layout] of placed) {
|
|
250
|
+
placed.set(id, clampLayout({ ...layout, x: layout.x - minX + SHELF_GAP_X, y: layout.y - minY + SHELF_GAP_Y }));
|
|
251
|
+
}
|
|
252
|
+
return placed;
|
|
253
|
+
}
|
|
254
|
+
/** Normalize a submitted width preset, defaulting to the middle size. */
|
|
255
|
+
export function normalizeWidth(raw) {
|
|
256
|
+
const value = String(raw ?? '');
|
|
257
|
+
const match = WIDTHS.find((width) => width === value);
|
|
258
|
+
return match ?? 'md';
|
|
259
|
+
}
|