carbon-tokenization / frontend /src /editor /frontmatter /frontmatter-store.ts
tfrere's picture
tfrere HF Staff
feat(editor): authors & affiliations manager modal
1f88239
Raw
History Blame Contribute Delete
8.96 kB
import * as Y from "yjs";
export interface Author {
name: string;
url?: string;
affiliations: number[];
}
export interface Affiliation {
name: string;
url?: string;
}
export interface LinkItem {
label: string;
url: string;
}
export interface FrontmatterData {
title: string;
subtitle: string;
description: string;
authors: Author[];
affiliations: Affiliation[];
published: string;
template: "article" | "paper";
banner: string;
doi: string;
showPdf: boolean;
tableOfContentsAutoCollapse: boolean;
licence: string;
pdfProOnly: boolean;
seoThumbImage: string;
links: LinkItem[];
}
const SCALAR_DEFAULTS: Omit<FrontmatterData, "authors" | "affiliations" | "links"> = {
title: "",
subtitle: "",
description: "",
published: "",
template: "article",
banner: "banner.html",
doi: "",
showPdf: true,
tableOfContentsAutoCollapse: false,
licence: "",
pdfProOnly: false,
seoThumbImage: "",
};
type ScalarKey = keyof typeof SCALAR_DEFAULTS;
type ArrayField = "authors" | "affiliations" | "links";
/**
* Collaborative frontmatter store backed by Yjs.
*
* Scalar fields (title, doi, template...) live in a Y.Map for atomic updates.
* Array fields (authors, affiliations, links) each get their own Y.Array
* so concurrent insertions merge correctly instead of last-write-wins.
*/
export function createFrontmatterStore(ydoc: Y.Doc) {
const ymap = ydoc.getMap("frontmatter");
const yAuthors = ydoc.getArray<Author>("frontmatter.authors");
const yAffiliations = ydoc.getArray<Affiliation>("frontmatter.affiliations");
const yLinks = ydoc.getArray<LinkItem>("frontmatter.links");
// --- Scalars ---
function getScalar<K extends ScalarKey>(key: K): FrontmatterData[K] {
const val = ymap.get(key);
return val !== undefined ? (val as FrontmatterData[K]) : (SCALAR_DEFAULTS[key] as FrontmatterData[K]);
}
function setScalar<K extends ScalarKey>(key: K, value: FrontmatterData[K]) {
ymap.set(key, value);
}
// --- Full snapshot ---
function getAll(): FrontmatterData {
const scalars: Record<string, unknown> = {};
for (const key of Object.keys(SCALAR_DEFAULTS) as ScalarKey[]) {
const val = ymap.get(key);
scalars[key] = val !== undefined ? val : SCALAR_DEFAULTS[key];
}
return {
...(scalars as Omit<FrontmatterData, "authors" | "affiliations" | "links">),
authors: yAuthors.toArray(),
affiliations: yAffiliations.toArray(),
links: yLinks.toArray(),
};
}
function setAll(data: Partial<FrontmatterData>) {
ydoc.transact(() => {
// `Object.entries` widens the value type to the full union of fields, so
// we narrow manually per key before pushing into the typed Y.Array.
if (data.authors !== undefined) {
yAuthors.delete(0, yAuthors.length);
yAuthors.push(data.authors);
}
if (data.affiliations !== undefined) {
yAffiliations.delete(0, yAffiliations.length);
yAffiliations.push(data.affiliations);
}
if (data.links !== undefined) {
yLinks.delete(0, yLinks.length);
yLinks.push(data.links);
}
for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue;
if (key === "authors" || key === "affiliations" || key === "links") continue;
ymap.set(key, value);
}
});
}
// --- Typed getters/setters for convenience ---
function get<K extends keyof FrontmatterData>(key: K): FrontmatterData[K] {
if (key === "authors") return yAuthors.toArray() as FrontmatterData[K];
if (key === "affiliations") return yAffiliations.toArray() as FrontmatterData[K];
if (key === "links") return yLinks.toArray() as FrontmatterData[K];
return getScalar(key as ScalarKey) as FrontmatterData[K];
}
function set<K extends keyof FrontmatterData>(key: K, value: FrontmatterData[K]) {
if (key === "authors") {
ydoc.transact(() => {
yAuthors.delete(0, yAuthors.length);
yAuthors.push(value as Author[]);
});
} else if (key === "affiliations") {
ydoc.transact(() => {
yAffiliations.delete(0, yAffiliations.length);
yAffiliations.push(value as Affiliation[]);
});
} else if (key === "links") {
ydoc.transact(() => {
yLinks.delete(0, yLinks.length);
yLinks.push(value as LinkItem[]);
});
} else {
setScalar(key as ScalarKey, value as FrontmatterData[ScalarKey]);
}
}
// --- Authors (concurrent-safe) ---
function addAuthor(author: Author) {
yAuthors.push([author]);
}
function updateAuthor(index: number, author: Author) {
if (index >= 0 && index < yAuthors.length) {
ydoc.transact(() => {
yAuthors.delete(index, 1);
yAuthors.insert(index, [author]);
});
}
}
function removeAuthor(index: number) {
if (index >= 0 && index < yAuthors.length) {
yAuthors.delete(index, 1);
}
}
/**
* Move an author from one position to another. `to` is the desired final
* index in the resulting array. No-op when out of range or unchanged.
* Authors don't reference each other, so no index remapping is needed.
*/
function moveAuthor(from: number, to: number) {
const len = yAuthors.length;
if (from === to || from < 0 || to < 0 || from >= len || to >= len) return;
ydoc.transact(() => {
const item = yAuthors.get(from);
yAuthors.delete(from, 1);
yAuthors.insert(to, [item]);
});
}
// --- Affiliations (concurrent-safe) ---
function addAffiliation(affiliation: Affiliation): number {
yAffiliations.push([affiliation]);
return yAffiliations.length; // 1-based index
}
/**
* Move an affiliation from one position to another. `to` is the desired
* final index in the resulting array.
*
* Authors reference affiliations by their 1-based position, so reordering
* the affiliation list would silently re-point every author's superscripts
* unless we remap. We compute the old->new position mapping the move
* implies and rewrite each author's `affiliations` accordingly, all inside
* a single Yjs transaction so collaborators see one atomic update.
*/
function moveAffiliation(from: number, to: number) {
const len = yAffiliations.length;
if (from === to || from < 0 || to < 0 || from >= len || to >= len) return;
ydoc.transact(() => {
const aff = yAffiliations.get(from);
yAffiliations.delete(from, 1);
yAffiliations.insert(to, [aff]);
// Replay the same move on the index sequence to derive old->new (1-based).
const order = Array.from({ length: len }, (_, i) => i);
const [moved] = order.splice(from, 1);
order.splice(to, 0, moved);
const oldToNew = new Map<number, number>();
order.forEach((oldPos, newPos) => oldToNew.set(oldPos + 1, newPos + 1));
const authors = yAuthors.toArray();
yAuthors.delete(0, yAuthors.length);
yAuthors.push(
authors.map((a) => ({
...a,
affiliations: (a.affiliations || []).map((idx) => oldToNew.get(idx) ?? idx),
})),
);
});
}
function updateAffiliation(index: number, affiliation: Affiliation) {
if (index >= 0 && index < yAffiliations.length) {
ydoc.transact(() => {
yAffiliations.delete(index, 1);
yAffiliations.insert(index, [affiliation]);
});
}
}
/**
* Remove an affiliation and reconcile every author's 1-based references:
* drop the references to the deleted one, then shift higher indices down by
* one so the remaining superscripts keep pointing at the right institution.
*/
function removeAffiliation(index: number) {
const len = yAffiliations.length;
if (index < 0 || index >= len) return;
const removed = index + 1; // 1-based id used by authors
ydoc.transact(() => {
yAffiliations.delete(index, 1);
const authors = yAuthors.toArray();
yAuthors.delete(0, yAuthors.length);
yAuthors.push(
authors.map((a) => ({
...a,
affiliations: (a.affiliations || [])
.filter((idx) => idx !== removed)
.map((idx) => (idx > removed ? idx - 1 : idx)),
})),
);
});
}
// --- Observe all changes ---
function observe(callback: () => void) {
ymap.observe(callback);
yAuthors.observe(callback);
yAffiliations.observe(callback);
yLinks.observe(callback);
return () => {
ymap.unobserve(callback);
yAuthors.unobserve(callback);
yAffiliations.unobserve(callback);
yLinks.unobserve(callback);
};
}
return {
get,
set,
getAll,
setAll,
addAuthor,
updateAuthor,
removeAuthor,
moveAuthor,
addAffiliation,
moveAffiliation,
updateAffiliation,
removeAffiliation,
observe,
};
}
export type FrontmatterStore = ReturnType<typeof createFrontmatterStore>;