269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
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"));
|
|
},
|
|
};
|
|
}
|