Route element hooks by ID and support field selectors
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import type {Commit,Id} from '../canvas-core/types.ts';
|
||||
import type {ElementLookup} from '../canvas-core/queries.ts';
|
||||
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
|
||||
export type ElementSourcePorts={
|
||||
getSnapshot():EngineSnapshot;
|
||||
subscribe(listener:(commit:Commit,snapshot:EngineSnapshot)=>void):()=>void;
|
||||
lookup(snapshot:EngineSnapshot,id:Id):ElementLookup;
|
||||
};
|
||||
/** One upstream subscription, ID buckets and cached lookup values, never another document.
|
||||
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
|
||||
export function createElementSources(ports:ElementSourcePorts){
|
||||
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
|
||||
const buckets=new Map<Id,Bucket>();let stop:(()=>void)|undefined,previous:EngineSnapshot|undefined;
|
||||
function changed(commit:Commit,next:EngineSnapshot){
|
||||
// The editor snapshots its listeners; this callback can already be unsubscribed.
|
||||
if(!previous||!buckets.size)return;
|
||||
const before=previous!;previous=next;
|
||||
const ids=new Set<Id>();
|
||||
if(before.document!==next.document){
|
||||
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
|
||||
else for(const id of buckets.keys())ids.add(id);
|
||||
}
|
||||
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
|
||||
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
|
||||
const notifications=new Set<()=>void>();
|
||||
for(const id of ids){
|
||||
const bucket=buckets.get(id);if(!bucket)continue;
|
||||
try{const value=ports.lookup(next,id);if(!bucket.failed&&equal(bucket.value,value))continue;bucket.value=value;bucket.failed=false;}catch{bucket.failed=true;/* Snapshot readers retry/raise through their own boundary. */}
|
||||
for(const notify of bucket.listeners)notifications.add(notify);
|
||||
}
|
||||
// All bucket values advance before observers run; a failing observer cannot starve others.
|
||||
let failed=false,error:unknown;
|
||||
for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}
|
||||
if(failed)throw error;
|
||||
}
|
||||
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
|
||||
let value:ElementLookup|undefined;
|
||||
return Object.freeze({
|
||||
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
|
||||
subscribe(notify:()=>void){
|
||||
let bucket=buckets.get(id);if(!bucket){bucket={value:ports.lookup(ports.getSnapshot(),id),listeners:new Set()};buckets.set(id,bucket);}
|
||||
// Distinct subscriptions may intentionally share the same callback identity.
|
||||
const listener=()=>notify();bucket.listeners.add(listener);
|
||||
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
|
||||
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
|
||||
},
|
||||
});
|
||||
}});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react';
|
||||
import {createSelectorStore,type ExternalSource} from './selectors.ts';
|
||||
import {createElementSources} from './element-sources.ts';
|
||||
import type {ProjectEngine} from '../canvas-runtime/workspace.ts';
|
||||
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
|
||||
import type {Target,Id,Data,Geometry,ElementRecord,Availability,Result} from '../canvas-core/types.ts';
|
||||
@@ -24,9 +25,14 @@ function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEq
|
||||
function useRootSource(){const {engine}=useCanvasBinding();return useMemo(()=>({getSnapshot:engine.getSnapshot,subscribe:(notify:()=>void)=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)}),[engine]);}
|
||||
export function useCanvas<T>(selector:(snapshot:EngineSnapshot)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{return useSelected(useRootSource(),selector,isEqual);}
|
||||
export type ElementView=ElementLookup|{readonly status:'stale';readonly element:ElementRecord};
|
||||
export function useElement(ref:Id|Target):ElementView{
|
||||
const elementSources=new WeakMap<ProjectEngine,ReturnType<typeof createElementSources>>();
|
||||
function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(engine);if(!sources){sources=createElementSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify),lookup:(root,id)=>engine.editor.getQueries(root.document).lookupElement(id)});elementSources.set(engine,sources);}return sources;}
|
||||
export function useElement(ref:Id|Target):ElementView;
|
||||
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
|
||||
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
|
||||
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
|
||||
return useCanvas(useCallback(snapshot=>{const found=engine.editor.getQueries(snapshot.document).lookupElement(id);return found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;},[engine,id,incarnation]),equal);
|
||||
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
|
||||
return useSelected(source,useCallback((found:ElementLookup)=>{const view=found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;return selector?selector(view):view;},[incarnation,selector]),(isEqual as ((a:T|ElementView,b:T|ElementView)=>boolean)|undefined)??(selector?Object.is:equal));
|
||||
}
|
||||
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
|
||||
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
|
||||
|
||||
Reference in New Issue
Block a user