Export template source from be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9:examples/local-first/packages/language-reader-presentation
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { referenceOf, useInterface } from "@quixos/camino-react";
|
||||
import { interfaces } from "./gen/client.js";
|
||||
import { sameCacheConfiguration } from "./cache.js";
|
||||
import { accepted, errorMessage, type DocNode, type FeatureNode } from "./model.js";
|
||||
import { preparationQueue, processorMatches, sameScope } from "./work.js";
|
||||
import type { Range } from "./canvas/types.js";
|
||||
import type { ReadingWindow } from "./canvas/pages.js";
|
||||
import type { AnnotationRender } from "./render-hooks.js";
|
||||
|
||||
type Planned = { feature: FeatureNode; range: Range; context: string; planning?: "click" | "hover" };
|
||||
/** One admission/cache path for proximity preparation, clicks, hover and retry.
|
||||
* A changed window replaces unsent plans. Admitted jobs retain their results.
|
||||
* Model preparation has two slots, shared by independent scopes. */
|
||||
export function useProcessing({
|
||||
document: doc,
|
||||
annotationsReady,
|
||||
features,
|
||||
revisions,
|
||||
revisionAliases = {},
|
||||
computations,
|
||||
renders,
|
||||
window,
|
||||
error,
|
||||
}: {
|
||||
document: DocNode;
|
||||
annotationsReady: boolean;
|
||||
features: FeatureNode[];
|
||||
revisions: Record<string, string>;
|
||||
revisionAliases?: Record<string, readonly string[]>;
|
||||
computations: Record<string, string>;
|
||||
renders: Record<string, AnnotationRender[] | undefined>;
|
||||
window: ReadingWindow;
|
||||
error: (message: string) => void;
|
||||
}) {
|
||||
const ops = useInterface(
|
||||
{ plan: interfaces.ReadingDocument.plan, generate: interfaces.ReadingDocument.generate },
|
||||
referenceOf(doc),
|
||||
);
|
||||
const jobs = doc.jobs.entries.map(({ node }) => node);
|
||||
const [planning, setPlanning] = useState<{ context: string; items: Planned[] }>({ context: "", items: [] }),
|
||||
[tick, setTick] = useState(0);
|
||||
const [clickScopes, setClickScopes] = useState<Record<string, Range[]>>({});
|
||||
const [intent, setIntent] = useState<Planned[]>([]);
|
||||
const submitted = useRef(new Set<string>()),
|
||||
admitting = useRef(new Map<string, Planned>()),
|
||||
focusSerial = useRef({ click: 0, hover: 0 });
|
||||
const featureKey = features
|
||||
.map(
|
||||
(f) =>
|
||||
`${referenceOf(f).object}:${computations[referenceOf(f).object]}:${f.data.enabled}:${revisions[f.data.processor]}`,
|
||||
)
|
||||
.join("|");
|
||||
const context = `${referenceOf(doc).object}:${doc.data.sourceRevision}:${featureKey}`;
|
||||
const currentContext = useRef(context);
|
||||
currentContext.current = context;
|
||||
const planningContext = [
|
||||
context,
|
||||
window.nearby.start,
|
||||
window.nearby.end,
|
||||
window.visible.start,
|
||||
window.visible.end,
|
||||
].join(":");
|
||||
const plans = planning.context === planningContext ? planning.items : [];
|
||||
const matches = (job: (typeof jobs)[number], { feature, range }: Planned) =>
|
||||
processorMatches(job.data, revisions[feature.data.processor], revisionAliases[feature.data.processor]) &&
|
||||
sameScope(
|
||||
{ ...job.data, configuration: job.configuration },
|
||||
doc.data.sourceRevision,
|
||||
computations[referenceOf(feature).object],
|
||||
range,
|
||||
);
|
||||
const keyFor = ({ feature, range }: Planned) =>
|
||||
[
|
||||
doc.data.sourceRevision,
|
||||
computations[referenceOf(feature).object],
|
||||
revisions[feature.data.processor],
|
||||
range.start,
|
||||
range.end,
|
||||
].join(":");
|
||||
function request(feature: FeatureNode, range: Range, retry = false): boolean {
|
||||
if (!annotationsReady || ops.status !== "ready" || range.end <= range.start || !revisions[feature.data.processor])
|
||||
return false;
|
||||
const item = { feature, range, context },
|
||||
key = keyFor(item);
|
||||
if (!retry && (submitted.current.has(key) || jobs.some((job) => matches(job, item)))) return false;
|
||||
submitted.current.add(key);
|
||||
setIntent((previous) => [...previous.filter((item) => keyFor(item) !== key), item]);
|
||||
admitting.current.set(key, item);
|
||||
void accepted(
|
||||
ops.value.generate.call({
|
||||
feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } },
|
||||
...range,
|
||||
retry,
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
).catch((cause) => {
|
||||
admitting.current.delete(key);
|
||||
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
|
||||
error(errorMessage(cause));
|
||||
setTick((value) => value + 1);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
useEffect(() => {
|
||||
let closed = false;
|
||||
setPlanning({ context: planningContext, items: [] });
|
||||
if (!annotationsReady || ops.status !== "ready" || window.nearby.end <= window.nearby.start) return;
|
||||
const timer = setTimeout(() => {
|
||||
void Promise.all(
|
||||
features
|
||||
.filter((f) => f.data.enabled && f.data.generation !== "focus")
|
||||
.map(async (feature) => {
|
||||
const ranges = await ops.value.plan.call({
|
||||
feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } },
|
||||
...window.nearby,
|
||||
visibleStart: window.visible.start,
|
||||
visibleEnd: window.visible.end,
|
||||
kind: "nearby",
|
||||
});
|
||||
return ranges.map((range) => ({ feature, range, context }));
|
||||
}),
|
||||
)
|
||||
.then((all) => {
|
||||
if (!closed) setPlanning({ context: planningContext, items: all.flat() });
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!closed) error(errorMessage(cause));
|
||||
});
|
||||
}, 180);
|
||||
return () => {
|
||||
closed = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [planningContext, ops.status, annotationsReady]);
|
||||
useEffect(() => {
|
||||
for (const [key, item] of admitting.current)
|
||||
if (jobs.some((job) => matches(job, item))) admitting.current.delete(key);
|
||||
setIntent((previous) => {
|
||||
const next = previous.filter((item) => item.context === context && !jobs.some((job) => matches(job, item)));
|
||||
return next.length === previous.length ? previous : next;
|
||||
});
|
||||
const queue = nearbyQueue();
|
||||
for (const { feature, range } of queue.admit) request(feature, range);
|
||||
}, [planning, doc.jobs.entries, context, tick, planningContext]);
|
||||
function nearbyQueue() {
|
||||
const pending = jobs.filter(
|
||||
(job) =>
|
||||
job.data.kind === "annotation" &&
|
||||
job.data.status === "queued" &&
|
||||
job.data.sourceRevision === doc.data.sourceRevision &&
|
||||
features.some(
|
||||
(f) =>
|
||||
f.data.enabled &&
|
||||
f.data.generation !== "focus" &&
|
||||
sameCacheConfiguration(computations[referenceOf(f).object], job.configuration) &&
|
||||
processorMatches(job.data, revisions[f.data.processor], revisionAliases[f.data.processor]),
|
||||
),
|
||||
);
|
||||
const inAdmission = [...admitting.current.values()].filter(
|
||||
(item) =>
|
||||
item.context === context &&
|
||||
item.feature.data.generation === "section" &&
|
||||
!jobs.some((job) => matches(job, item)),
|
||||
);
|
||||
return preparationQueue(
|
||||
annotationsReady && ops.status === "ready"
|
||||
? plans
|
||||
.filter(
|
||||
(item) => item.context === context && item.feature.data.enabled && revisions[item.feature.data.processor],
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
key: keyFor(item),
|
||||
configuration: computations[referenceOf(item.feature).object],
|
||||
local: item.feature.data.generation === "local",
|
||||
}))
|
||||
: [],
|
||||
window.visible,
|
||||
pending.length + inAdmission.length,
|
||||
submitted.current,
|
||||
new Set(plans.filter((item) => jobs.some((job) => matches(job, item))).map(keyFor)),
|
||||
);
|
||||
}
|
||||
async function focus(range: Range, kind: "click" | "hover") {
|
||||
const serial = ++focusSerial.current[kind];
|
||||
if (kind === "click") setClickScopes({});
|
||||
const selected = features.filter(
|
||||
(f) =>
|
||||
f.data.enabled &&
|
||||
f.data.generation === "focus" &&
|
||||
(renders[f.data.processor] ?? [{ reveal: f.data.reveal }]).some((render) =>
|
||||
kind === "hover" ? render.reveal === "hover" : render.reveal !== "hover",
|
||||
),
|
||||
);
|
||||
// Covering the clicked word does not imply a cache hit for the newly
|
||||
// planned sentence/passage. Show intent now; exact scope identity decides
|
||||
// reuse after planning, even while operation-input admission is pending.
|
||||
setIntent((previous) => [
|
||||
...previous.filter((item) => item.planning !== kind),
|
||||
...selected.map((feature) => ({ feature, range, context, planning: kind })),
|
||||
]);
|
||||
if (!annotationsReady || ops.status !== "ready") return;
|
||||
await Promise.all(
|
||||
selected.map(async (feature) => {
|
||||
try {
|
||||
const ranges = await ops.value.plan.call({
|
||||
feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } },
|
||||
...range,
|
||||
visibleStart: range.start,
|
||||
visibleEnd: range.end,
|
||||
kind: "focus",
|
||||
});
|
||||
if (serial === focusSerial.current[kind] && currentContext.current === context) {
|
||||
setIntent((previous) =>
|
||||
previous.filter(
|
||||
(item) => !(item.planning === kind && referenceOf(item.feature).object === referenceOf(feature).object),
|
||||
),
|
||||
);
|
||||
if (kind === "click")
|
||||
setClickScopes((previous) => ({ ...previous, [referenceOf(feature).object]: [...ranges] }));
|
||||
for (const scope of ranges) request(feature, scope);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (serial === focusSerial.current[kind] && currentContext.current === context) {
|
||||
setIntent((previous) =>
|
||||
previous.filter(
|
||||
(item) => !(item.planning === kind && referenceOf(item.feature).object === referenceOf(feature).object),
|
||||
),
|
||||
);
|
||||
error(errorMessage(cause));
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
useEffect(
|
||||
() => () => {
|
||||
focusSerial.current.click++;
|
||||
focusSerial.current.hover++;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return {
|
||||
ready:
|
||||
annotationsReady &&
|
||||
ops.status === "ready" &&
|
||||
features.filter((feature) => feature.data.enabled).every((feature) => !!revisions[feature.data.processor]),
|
||||
request,
|
||||
focus,
|
||||
clickScopes,
|
||||
pending: [
|
||||
...nearbyQueue().waiting,
|
||||
...intent.filter(
|
||||
(item) =>
|
||||
item.context === context &&
|
||||
features.some(
|
||||
(feature) => referenceOf(feature).object === referenceOf(item.feature).object && feature.data.enabled,
|
||||
) &&
|
||||
!jobs.some((job) => matches(job, item)),
|
||||
),
|
||||
],
|
||||
cancelHover: () => {
|
||||
focusSerial.current.hover++;
|
||||
setIntent((previous) => previous.filter((item) => item.planning !== "hover"));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user