mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22:20 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -0,0 +1,677 @@
|
||||
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { StoreApi } from "zustand/vanilla";
|
||||
import type { SemanticCssColorToken } from "./color-tokens";
|
||||
import type {
|
||||
CompileWorkerInput,
|
||||
CompileWorkerResponse,
|
||||
PreflightWorkerInput,
|
||||
PreflightWorkerResponse,
|
||||
SemanticCssEditorMetadata,
|
||||
} from "./protocol";
|
||||
import { create } from "zustand/react";
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import {
|
||||
buildSemanticTree,
|
||||
getTemplateSemanticManifest,
|
||||
semanticNodeKeys,
|
||||
shouldShowResumeHeader,
|
||||
} from "@reactive-resume/pdf/semantic-tree";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
|
||||
|
||||
export type StylesheetCanonicalState = {
|
||||
stylesheet: SemanticStylesheet;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
};
|
||||
|
||||
type StylesheetMutationResult = StylesheetCanonicalState & {
|
||||
editGeneration: number;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
};
|
||||
|
||||
type EditMutation = {
|
||||
id: string;
|
||||
expectedRevision: number;
|
||||
expectedRenderDataVersion: number;
|
||||
editGeneration: number;
|
||||
transition: "edit_source";
|
||||
source: StylesheetSource;
|
||||
};
|
||||
|
||||
type RestoreMutation = {
|
||||
id: string;
|
||||
expectedRevision: number;
|
||||
expectedRenderDataVersion: number;
|
||||
editGeneration: number;
|
||||
transition: "restore_history";
|
||||
restore: SemanticStylesheet;
|
||||
};
|
||||
|
||||
type ActivateMutation = Omit<EditMutation, "transition"> & { transition: "activate" };
|
||||
type DeactivateMutation = Omit<EditMutation, "transition" | "source"> & { transition: "deactivate" };
|
||||
type StylesheetMutation = EditMutation | RestoreMutation | ActivateMutation | DeactivateMutation;
|
||||
|
||||
type Candidate =
|
||||
| { generation: number; transition: "edit_source"; source: StylesheetSource }
|
||||
| { generation: number; transition: "restore_history"; restore: SemanticStylesheet }
|
||||
| { generation: number; transition: "activate"; source: StylesheetSource }
|
||||
| { generation: number; transition: "deactivate" };
|
||||
|
||||
export type StylesheetStoreState = {
|
||||
resumeId?: string;
|
||||
mode: SemanticStylesheet["mode"];
|
||||
source: StylesheetSource;
|
||||
applied: StylesheetSource;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
editGeneration: number;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
colorTokens: readonly SemanticCssColorToken[];
|
||||
editorMetadata: SemanticCssEditorMetadata;
|
||||
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
|
||||
restoreLocked: boolean;
|
||||
focused: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undoStack: SemanticStylesheet[];
|
||||
redoStack: SemanticStylesheet[];
|
||||
setSourceText(text: string): void;
|
||||
setFocused(focused: boolean): void;
|
||||
activate(): void;
|
||||
deactivate(): void;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
refreshIntelligence(): void;
|
||||
};
|
||||
|
||||
type RuntimeDependencies = {
|
||||
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse>;
|
||||
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse>;
|
||||
mutate(input: StylesheetMutation, signal: AbortSignal): Promise<StylesheetMutationResult>;
|
||||
destroy?(): void;
|
||||
};
|
||||
|
||||
type CreateStylesheetStoreRuntimeOptions = RuntimeDependencies & {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
debounceMs?: number;
|
||||
store?: StoreApi<StylesheetStoreState>;
|
||||
};
|
||||
|
||||
const emptySource = (): StylesheetSource => ({ languageVersion: 1, text: "@version 1;\n" });
|
||||
const emptySemanticTree = (): SemanticNode => ({
|
||||
key: "resume",
|
||||
kind: "resume",
|
||||
attributes: {},
|
||||
roles: [],
|
||||
children: [],
|
||||
});
|
||||
const HISTORY_COALESCE_MS = 500;
|
||||
const MAX_HISTORY_ENTRIES = 50;
|
||||
|
||||
const inactiveState = (): Omit<
|
||||
StylesheetStoreState,
|
||||
"setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence"
|
||||
> => ({
|
||||
resumeId: undefined,
|
||||
mode: "legacy",
|
||||
source: emptySource(),
|
||||
applied: emptySource(),
|
||||
revision: 0,
|
||||
renderDataVersion: 0,
|
||||
editGeneration: 0,
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
editorMetadata: { semanticTree: emptySemanticTree(), templateParts: [] },
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
focused: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
});
|
||||
|
||||
const sourceFromText = (source: StylesheetSource, text: string): StylesheetSource => ({ ...source, text });
|
||||
const sourcesEqual = (left: StylesheetSource, right: StylesheetSource) =>
|
||||
left.languageVersion === right.languageVersion && left.text === right.text;
|
||||
const isEditorFocused = () =>
|
||||
typeof document !== "undefined" && document.activeElement instanceof HTMLElement
|
||||
? document.activeElement.closest(".cm-editor") !== null
|
||||
: false;
|
||||
const currentStylesheet = (state: StylesheetStoreState): SemanticStylesheet => ({
|
||||
mode: state.mode,
|
||||
source: structuredClone(state.source),
|
||||
applied: structuredClone(state.applied),
|
||||
});
|
||||
const appendHistory = (stack: SemanticStylesheet[], value: SemanticStylesheet) =>
|
||||
[...stack, value].slice(-MAX_HISTORY_ENTRIES);
|
||||
|
||||
const pageDimensions = (data: ResumeData) => {
|
||||
const format = data.metadata.page.format;
|
||||
const size = format === "letter" ? { width: 612, height: 792 } : { width: 595.28, height: 841.89 };
|
||||
return data.metadata.layout.pages.map((_page, index) => ({
|
||||
pageKey: semanticNodeKeys.page(index + 1),
|
||||
...size,
|
||||
}));
|
||||
};
|
||||
|
||||
const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
|
||||
const pages = data.metadata.layout.pages.map((page, index) =>
|
||||
buildSemanticTree({
|
||||
data,
|
||||
template: data.metadata.template,
|
||||
page,
|
||||
pageNumber: index + 1,
|
||||
showHeader: shouldShowResumeHeader(data, index),
|
||||
}),
|
||||
);
|
||||
const semanticTree: SemanticNode = {
|
||||
key: semanticNodeKeys.resume(),
|
||||
kind: "resume",
|
||||
attributes: { template: data.metadata.template },
|
||||
roles: [],
|
||||
children: pages.flatMap(({ children }) => children),
|
||||
};
|
||||
return {
|
||||
semanticTree,
|
||||
templateParts: getTemplateSemanticManifest(data.metadata.template).parts.map(({ name }) => name),
|
||||
};
|
||||
};
|
||||
|
||||
const compileInput = (
|
||||
data: ResumeData,
|
||||
source: StylesheetSource,
|
||||
editGeneration: number,
|
||||
semanticTree: SemanticNode,
|
||||
): CompileWorkerInput => {
|
||||
return {
|
||||
editGeneration,
|
||||
source,
|
||||
semanticTree,
|
||||
baseSettings: {
|
||||
picture: data.picture,
|
||||
template: data.metadata.template,
|
||||
design: data.metadata.design,
|
||||
typography: data.metadata.typography,
|
||||
page: data.metadata.page,
|
||||
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
|
||||
},
|
||||
pages: pageDimensions(data),
|
||||
};
|
||||
};
|
||||
|
||||
const conflictState = (error: unknown): StylesheetCanonicalState | undefined => {
|
||||
if (!error || typeof error !== "object") return;
|
||||
const value = error as { code?: string; data?: { state?: StylesheetCanonicalState } };
|
||||
return value.code === "STYLESHEET_REVISION_CONFLICT" ? value.data?.state : undefined;
|
||||
};
|
||||
|
||||
export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRuntimeOptions) {
|
||||
let resumeData = structuredClone(options.resumeData);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let inFlight: Candidate | undefined;
|
||||
let pending: Candidate | undefined;
|
||||
let latestCandidate: Candidate | undefined;
|
||||
let deferredCanonical: StylesheetCanonicalState | undefined;
|
||||
let validationEpoch = 0;
|
||||
let intelligenceEpoch = 0;
|
||||
let historyLastEditAt = 0;
|
||||
let historyCanCoalesce = false;
|
||||
let destroyed = false;
|
||||
const abortController = new AbortController();
|
||||
const debounceMs = options.debounceMs ?? 180;
|
||||
const initial = options.initial.stylesheet;
|
||||
let editorMetadata = createEditorMetadata(resumeData);
|
||||
const store =
|
||||
options.store ??
|
||||
createStore<StylesheetStoreState>(() => ({
|
||||
...inactiveState(),
|
||||
setSourceText: () => {},
|
||||
setFocused: () => {},
|
||||
activate: () => {},
|
||||
deactivate: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
refreshIntelligence: () => {},
|
||||
}));
|
||||
|
||||
const patch = (next: Partial<StylesheetStoreState>) => store.setState(next);
|
||||
const replaceCanonical = (canonical: StylesheetCanonicalState, preserveSource: boolean) => {
|
||||
const state = store.getState();
|
||||
const next: Partial<StylesheetStoreState> = {
|
||||
revision: Math.max(state.revision, canonical.revision),
|
||||
renderDataVersion: Math.max(state.renderDataVersion, canonical.renderDataVersion),
|
||||
};
|
||||
if (canonical.revision >= state.revision) {
|
||||
next.mode = canonical.stylesheet.mode;
|
||||
const nextSource = preserveSource ? state.source : canonical.stylesheet.source;
|
||||
next.source = nextSource;
|
||||
next.applied = canonical.stylesheet.applied;
|
||||
if (!sourcesEqual(nextSource, state.source)) {
|
||||
intelligenceEpoch += 1;
|
||||
next.colorTokens = [];
|
||||
}
|
||||
}
|
||||
patch(next);
|
||||
};
|
||||
const resetHistoryCoalescing = () => {
|
||||
historyLastEditAt = 0;
|
||||
historyCanCoalesce = false;
|
||||
};
|
||||
|
||||
const startNext = () => {
|
||||
if (destroyed || inFlight || !pending) return;
|
||||
const candidate = pending;
|
||||
pending = undefined;
|
||||
inFlight = candidate;
|
||||
const requestValidationEpoch = validationEpoch;
|
||||
const state = store.getState();
|
||||
const common = {
|
||||
id: options.resumeId,
|
||||
expectedRevision: state.revision,
|
||||
expectedRenderDataVersion: state.renderDataVersion,
|
||||
editGeneration: candidate.generation,
|
||||
};
|
||||
let input: StylesheetMutation;
|
||||
if (candidate.transition === "edit_source" || candidate.transition === "activate") {
|
||||
input = { ...common, transition: candidate.transition, source: candidate.source };
|
||||
} else if (candidate.transition === "restore_history") {
|
||||
input = { ...common, transition: "restore_history", restore: candidate.restore };
|
||||
} else {
|
||||
input = { ...common, transition: "deactivate" };
|
||||
}
|
||||
patch({ status: "saving" });
|
||||
|
||||
void options
|
||||
.mutate(input, abortController.signal)
|
||||
.then((result) => {
|
||||
if (destroyed) return;
|
||||
const state = store.getState();
|
||||
const staleStylesheet = result.revision < state.revision;
|
||||
patch({
|
||||
revision: Math.max(state.revision, result.revision),
|
||||
renderDataVersion: Math.max(state.renderDataVersion, result.renderDataVersion),
|
||||
});
|
||||
if (result.editGeneration !== store.getState().editGeneration) return;
|
||||
if (staleStylesheet) return;
|
||||
const sourceChanged = !sourcesEqual(result.stylesheet.source, state.source);
|
||||
if (sourceChanged) intelligenceEpoch += 1;
|
||||
patch({
|
||||
mode: result.stylesheet.mode,
|
||||
source: result.stylesheet.source,
|
||||
applied: result.stylesheet.applied,
|
||||
diagnostics: result.diagnostics,
|
||||
colorTokens: sourceChanged ? [] : state.colorTokens,
|
||||
status: result.diagnostics.some(({ severity }) => severity === "error") ? "error" : "applied",
|
||||
});
|
||||
if (latestCandidate?.generation === result.editGeneration) latestCandidate = undefined;
|
||||
if (deferredCanonical && result.revision >= deferredCanonical.revision) deferredCanonical = undefined;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (destroyed) return;
|
||||
const canonical = conflictState(error);
|
||||
if (!canonical) {
|
||||
patch({ status: "error" });
|
||||
return;
|
||||
}
|
||||
replaceCanonical(canonical, true);
|
||||
if (requestValidationEpoch === validationEpoch) pending ??= candidate;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
startNext();
|
||||
});
|
||||
};
|
||||
|
||||
const queue = (candidate: Candidate) => {
|
||||
latestCandidate = candidate;
|
||||
pending = candidate;
|
||||
startNext();
|
||||
};
|
||||
|
||||
const processCandidate = async (candidate: Candidate) => {
|
||||
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
|
||||
const candidateValidationEpoch = validationEpoch;
|
||||
if (candidate.transition === "deactivate") {
|
||||
queue(candidate);
|
||||
return;
|
||||
}
|
||||
const source = candidate.transition === "restore_history" ? candidate.restore.applied : candidate.source;
|
||||
patch({ status: "compiling" });
|
||||
let compiled: CompileWorkerResponse;
|
||||
try {
|
||||
compiled = await options.compile(
|
||||
compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (destroyed || compiled.editGeneration !== store.getState().editGeneration) return;
|
||||
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||
|
||||
if (!compiled.program) {
|
||||
if (candidate.transition === "edit_source") queue(candidate);
|
||||
else patch({ status: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (compiled.program) {
|
||||
patch({ status: "preflighting" });
|
||||
let preflight: PreflightWorkerResponse;
|
||||
try {
|
||||
preflight = await options.preflight({
|
||||
editGeneration: candidate.generation,
|
||||
input: { data: resumeData, template: resumeData.metadata.template, stylesheet: source },
|
||||
limits: {
|
||||
maxPages: 20,
|
||||
maxBytes: 10_000_000,
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (candidate.generation !== store.getState().editGeneration) return;
|
||||
patch({ status: "error" });
|
||||
if (candidate.transition === "edit_source") queue(candidate);
|
||||
return;
|
||||
}
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return;
|
||||
if (!preflight.result.ok) {
|
||||
patch({ diagnostics: [...compiled.diagnostics, ...preflight.result.diagnostics], status: "error" });
|
||||
if (candidate.transition !== "edit_source") return;
|
||||
}
|
||||
}
|
||||
|
||||
queue(candidate);
|
||||
};
|
||||
|
||||
const schedule = (candidate: Candidate) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
latestCandidate = candidate;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void processCandidate(candidate);
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const restore = (target: SemanticStylesheet, opposite: "undoStack" | "redoStack") => {
|
||||
const state = store.getState();
|
||||
const stack = opposite === "undoStack" ? state.undoStack : state.redoStack;
|
||||
const previous = stack.at(-1);
|
||||
if (!previous) return;
|
||||
const generation = state.editGeneration + 1;
|
||||
const other = opposite === "undoStack" ? "redoStack" : "undoStack";
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
source: previous.source,
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
[opposite]: stack.slice(0, -1),
|
||||
[other]: appendHistory(state[other], target),
|
||||
canUndo: opposite === "redoStack" || stack.length > 1,
|
||||
canRedo: opposite === "undoStack" || stack.length > 1,
|
||||
});
|
||||
schedule({ generation, transition: "restore_history", restore: previous });
|
||||
};
|
||||
|
||||
store.setState({
|
||||
resumeId: options.resumeId,
|
||||
mode: initial.mode,
|
||||
source: structuredClone(initial.source),
|
||||
applied: structuredClone(initial.applied),
|
||||
revision: options.initial.revision,
|
||||
renderDataVersion: options.initial.renderDataVersion,
|
||||
editGeneration: 0,
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
editorMetadata,
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
focused: false,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
setSourceText(text) {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || text === state.source.text) return;
|
||||
const generation = state.editGeneration + 1;
|
||||
const nextSource = sourceFromText(state.source, text);
|
||||
const now = Date.now();
|
||||
const undoStack =
|
||||
historyCanCoalesce && now - historyLastEditAt <= HISTORY_COALESCE_MS
|
||||
? state.undoStack
|
||||
: appendHistory(state.undoStack, currentStylesheet(state));
|
||||
historyLastEditAt = now;
|
||||
historyCanCoalesce = true;
|
||||
patch({
|
||||
source: nextSource,
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack,
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
schedule({ generation, transition: "edit_source", source: nextSource });
|
||||
},
|
||||
setFocused(focused) {
|
||||
patch({ focused });
|
||||
if (focused || !deferredCanonical) return;
|
||||
const canonical = deferredCanonical;
|
||||
deferredCanonical = undefined;
|
||||
const candidate = latestCandidate;
|
||||
const hasLocalDraft =
|
||||
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
replaceCanonical(canonical, hasLocalDraft);
|
||||
if (hasLocalDraft && candidate) schedule(candidate);
|
||||
else resetHistoryCoalescing();
|
||||
},
|
||||
activate() {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || state.mode === "semantic") return;
|
||||
const generation = state.editGeneration + 1;
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
schedule({ generation, transition: "activate", source: state.source });
|
||||
},
|
||||
deactivate() {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || state.mode === "legacy") return;
|
||||
const generation = state.editGeneration + 1;
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
queue({ generation, transition: "deactivate" });
|
||||
},
|
||||
undo() {
|
||||
if (store.getState().restoreLocked) return;
|
||||
restore(currentStylesheet(store.getState()), "undoStack");
|
||||
},
|
||||
redo() {
|
||||
if (store.getState().restoreLocked) return;
|
||||
restore(currentStylesheet(store.getState()), "redoStack");
|
||||
},
|
||||
refreshIntelligence() {
|
||||
const state = store.getState();
|
||||
const generation = state.editGeneration;
|
||||
const source = structuredClone(state.source);
|
||||
const requestEpoch = ++intelligenceEpoch;
|
||||
void options
|
||||
.compile(compileInput(resumeData, source, generation, editorMetadata.semanticTree))
|
||||
.then((compiled) => {
|
||||
const current = store.getState();
|
||||
if (
|
||||
destroyed ||
|
||||
requestEpoch !== intelligenceEpoch ||
|
||||
current.editGeneration !== generation ||
|
||||
!sourcesEqual(current.source, source)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
replaceResumeSnapshot(data: ResumeData, canonical: StylesheetCanonicalState) {
|
||||
const candidate = latestCandidate;
|
||||
resumeData = structuredClone(data);
|
||||
editorMetadata = createEditorMetadata(resumeData);
|
||||
patch({ editorMetadata });
|
||||
const renderDataChanged = canonical.renderDataVersion > store.getState().renderDataVersion;
|
||||
const preserveSource = store.getState().focused || isEditorFocused() || candidate !== undefined;
|
||||
replaceCanonical(canonical, preserveSource);
|
||||
if (renderDataChanged) {
|
||||
validationEpoch += 1;
|
||||
pending = undefined;
|
||||
if (candidate) schedule(candidate);
|
||||
}
|
||||
},
|
||||
rebaseCanonical(canonical: StylesheetCanonicalState) {
|
||||
const candidate = latestCandidate;
|
||||
const hasLocalDraft =
|
||||
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
const focused = store.getState().focused || isEditorFocused();
|
||||
const sourceChanged = store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
if (focused && sourceChanged && canonical.revision >= store.getState().revision) deferredCanonical = canonical;
|
||||
const preserveSource = (focused && sourceChanged) || hasLocalDraft;
|
||||
replaceCanonical(canonical, preserveSource);
|
||||
if (hasLocalDraft && candidate) schedule(candidate);
|
||||
else if (!preserveSource) resetHistoryCoalescing();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
abortController.abort();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
pending = undefined;
|
||||
latestCandidate = undefined;
|
||||
deferredCanonical = undefined;
|
||||
options.destroy?.();
|
||||
store.setState(inactiveState());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const useStylesheetStore = create<StylesheetStoreState>(() => ({
|
||||
...inactiveState(),
|
||||
setSourceText: () => {},
|
||||
setFocused: () => {},
|
||||
activate: () => {},
|
||||
deactivate: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
refreshIntelligence: () => {},
|
||||
}));
|
||||
|
||||
let activeRuntime: ReturnType<typeof createStylesheetStoreRuntime> | undefined;
|
||||
declare const stylesheetRuntimeTokenBrand: unique symbol;
|
||||
export type StylesheetRuntimeToken = Readonly<{ [stylesheetRuntimeTokenBrand]: true }>;
|
||||
let activeRuntimeToken: StylesheetRuntimeToken | undefined;
|
||||
|
||||
const compilerClient = () =>
|
||||
createCompileWorkerClient(
|
||||
() =>
|
||||
new Worker(new URL("./stylesheet.worker.ts", import.meta.url), { type: "module", name: "semantic-css-compiler" }),
|
||||
);
|
||||
const preflightClient = () =>
|
||||
createPreflightWorkerClient(
|
||||
() =>
|
||||
new Worker(new URL("./preflight.worker.ts", import.meta.url), { type: "module", name: "semantic-css-preflight" }),
|
||||
5_000,
|
||||
);
|
||||
|
||||
export function initializeStylesheetStore(input: {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
}) {
|
||||
activeRuntime?.destroy();
|
||||
const compiler = compilerClient();
|
||||
const preflight = preflightClient();
|
||||
preflight.warmup();
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
...input,
|
||||
store: useStylesheetStore,
|
||||
compile: compiler.compile,
|
||||
preflight: preflight.preflight,
|
||||
mutate: (mutation, signal) => orpc.resume.stylesheet.mutate.call(mutation, { signal }),
|
||||
destroy: () => {
|
||||
compiler.destroy();
|
||||
preflight.destroy();
|
||||
},
|
||||
});
|
||||
activeRuntime = runtime;
|
||||
activeRuntimeToken = {} as StylesheetRuntimeToken;
|
||||
return () => {
|
||||
if (activeRuntime?.store.getState().resumeId !== input.resumeId) return;
|
||||
activeRuntime.destroy();
|
||||
activeRuntime = undefined;
|
||||
activeRuntimeToken = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function lockStylesheetStoreForRestore(resumeId: string): StylesheetRuntimeToken | undefined {
|
||||
if (!activeRuntime || !activeRuntimeToken) return;
|
||||
const state = activeRuntime.store.getState();
|
||||
if (state.resumeId !== resumeId || state.restoreLocked) return;
|
||||
activeRuntime.store.setState({ restoreLocked: true });
|
||||
return activeRuntimeToken;
|
||||
}
|
||||
|
||||
export function unlockStylesheetStoreAfterRestore(token: StylesheetRuntimeToken | undefined): boolean {
|
||||
if (!activeRuntime || !token || activeRuntimeToken !== token) return false;
|
||||
activeRuntime.store.setState({ restoreLocked: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function replaceStylesheetStoreAfterRestore(input: {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
token: StylesheetRuntimeToken | undefined;
|
||||
}): boolean {
|
||||
if (
|
||||
!activeRuntime ||
|
||||
!input.token ||
|
||||
activeRuntimeToken !== input.token ||
|
||||
activeRuntime.store.getState().resumeId !== input.resumeId ||
|
||||
!activeRuntime.store.getState().restoreLocked
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
initializeStylesheetStore(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function refreshStylesheetStore(resumeId: string, resumeData?: ResumeData) {
|
||||
if (!activeRuntime || activeRuntime.store.getState().resumeId !== resumeId) return;
|
||||
const canonical = await orpc.resume.stylesheet.getState.call({ id: resumeId });
|
||||
if (resumeData) activeRuntime.replaceResumeSnapshot(resumeData, canonical);
|
||||
else activeRuntime.rebaseCanonical(canonical);
|
||||
}
|
||||
Reference in New Issue
Block a user