Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

History
254 lines (182 loc) · 8.64 KB

File metadata and controls

254 lines (182 loc) · 8.64 KB

Angular Signals adapter

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; all mutations fire those signals, so templates, computeds, and effects update automatically — no ChangeDetectorRef, no NgZone.

Install

@angular/core is a peer dependency. The adapter ships with patchwork.

npm install @maxjay/patchwork @angular/core

The peer dep is optional — install patchwork without Angular if you only use the core engine. The adapter only loads if you import from @maxjay/patchwork/angular.

Quick start

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

API

createPatchworkStore<T>(base, options?)

Wraps a new Engine in a reactive store.

conststore=createPatchworkStore<MyConfig>(initialDoc,{ schema });

fromEngine<T>(engine)

Wraps an existing Engine. Useful when the engine is created elsewhere — e.g., shared with non-Angular code, hydrated from a snapshot.

constengine=newEngine(initial);conststore=fromEngine(engine);

⚠️ Mutations applied directly to the wrapped engine bypass the reactive layer. Always go through the store.

Reactive reads (return Signal)

MethodReturnsSource
store.draftSignal<T>whole draft
store.baseSignal<T>whole base
store.get<U>(path)Signal<Array<{path, value: U}>>draft, JSONPath query
store.getBase<U>(path)Signal<Array<{path, value: U}>>base, JSONPath query
store.getValue<U>(path)Signal<U>draft, strict single-match
store.getValueBase<U>(path)Signal<U>base, strict single-match
store.diff(path?, options?)Signal<DiffOp[]>structural diff — options: key, includeUnchanged, cascade

Typed generics

The <U> type parameter is optional and defaults to JsonValue. Declare it to get a typed signal without a cast:

// Without generic — requires a cast at the call siteitems=this.store.getValue('$.items')asSignal<Item[]>;// With generic — typed directlyitems=this.store.getValue<Item[]>('$.items');groups=this.store.getValue<Group[]>('$.groups');members=this.store.getValue<Member[]>('$.members');

This works the same way for get<U>, getBase<U>, and getValueBase<U>.

Caching

Methods that take args (everything except draft/base) return a newSignal on each call. Assign once to a class field — don't call them in a template hot path. This is the same pattern as Angular's own computed().

// ✅ Right — created onceport=this.store.getValue<number>('$.server.port');// ❌ Wrong — new Signal per change-detection cycle
template: `{{ store.getValue('$.server.port')() }}`

Mutations (sync, no return)

add, replace, delete, move, copy, revert — same signatures as Engine. Each fires the draft signal.

restore(op) — inverts a DiffOp from diff() and pushes it onto the undo stack. Fires the draft signal.

undo, redo — fire both draft and base signals.

accept — promotes draft to base, fires the base signal. decline — resets draft from base, fires the draft signal.

Ephemeral sessions

store.beginEphemeral(), store.commitEphemeral(), store.discardEphemeral() — same semantics as Engine. Only available on root stores — scope() returns a store that throws on these. Use the root store for ephemeral.

store.scope<U>(path): PatchworkStore<U>

Sub-store rooted at a subtree. Shares the parent's signal ticks — mutations through either side update both. Use to scope a component or feature module to a slice of the document without losing reactivity.

constnetwork=store.scope<NetworkConfig>('$.network');network.replace('$.timeout',5000);// store.draft().network.timeout === 5000 too

network.accept() commits the network subtree only — the rest of base stays put. network.diff() is automatically scoped to that subtree.

store.engine

Escape hatch. Returns the underlying Engine or NodeEngine. Use for anything not surfaced through the store — but going around the store skips signal updates.

Patterns

Change-highlighting UI

diff doubles as both a "has unsaved changes" indicator and a per-row state source. With identity-keyed diffing, the identity field on add/remove ops tells you exactly which item was affected — no path parsing required:

@Component({template: ` @for (item of items(); track item.id) { <div [class]="stateOf(item.id)">{{ item.name }}</div> } <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classItemList{store=createPatchworkStore<any>({items: [...]},{schema: {type: 'object',properties: {items: {type: 'array','x-key': 'id',items: {type: 'object'}},},},},);items=this.store.getValue<Item[]>('$.items');diff=this.store.diff('$.items');stateOf(id: string): string{constops=this.diff();if(ops.some(o=>o.op==='add'&&o.identity===id))return'added';if(ops.some(o=>o.op==='remove'&&o.identity===id))return'removed';if(ops.some(o=>o.op==='replace'&&o.identity===id))return'modified';if(ops.some(o=>o.op==='move'&&o.identity===id))return'displaced';return'unchanged';}}

Form binding with ephemeral commit

Bind input changes live but collapse to one undo entry on blur:

@Component({template: `<input [value]="port()" (focus)="store.beginEphemeral()" (input)="onInput($event)" (blur)="store.commitEphemeral()" >`,})classPortField{store=createPatchworkStore({port: 8080});port=this.store.getValue<number>('$.port');onInput(e: Event){this.store.replace('$.port',+(e.targetasHTMLInputElement).value);}}

One undo() snaps the field back to the value it had on focus. discardEphemeral() cancels instead — unwinds all session mutations with no history trace.

Save / discard buttons

diff doubles as a "has unsaved changes" indicator:

hasChanges=computed(()=>this.store.diff()().length>0);

Or directly in the template:

<button(click)="store.accept()" [disabled]="!diff().length">Save</button>

Sharing across components

Put the store on a service:

@Injectable({providedIn: 'root'})classConfigStore{privateinner=createPatchworkStore<Config>(getInitial());readonlydraft=this.inner.draft;readonlydiff=this.inner.diff();add(...args: Parameters<typeofthis.inner.add>){this.inner.add(...args);}replace(...args: Parameters<typeofthis.inner.replace>){this.inner.replace(...args);}accept(){this.inner.accept();}decline(){this.inner.decline();}}

Any component that injects ConfigStore and reads its signals participates in the same reactive document.

Scoped feature modules

Use scope() to give a feature module its own store view without wiring the full document:

@Injectable()classNetworkSettingsStore{privateroot=inject(ConfigStore).inner;readonlystore=this.root.scope<NetworkConfig>('$.network');readonlytimeout=this.store.getValue<number>('$.timeout');readonlydiff=this.store.diff();}

Mutations through NetworkSettingsStore are visible on the root store's signals, and vice versa. store.accept() commits only the network subtree.

Notes on reactivity

The store updates engine state in-place (no structuredClone per mutation) and uses equal: () => false on its internal tick signals to force propagation regardless of reference equality. This keeps the hot path cheap — mutating 100 fields doesn't allocate 100 cloned documents — while keeping signal semantics correct.

If you read engine.draft directly (without going through the store), you get the same reference the store holds. Don't mutate it through the engine after that — the store's signal won't fire and the UI will desync. Always go through the store for writes.