refactor: rename to Science Article Template Editor, remove MDX export, fix settings drawer
Browse files- Rename project from "Collab Editor" to "Science Article Template Editor"
across README, package.json, HTML titles, and docs
- Remove MDX export: delete to-mdx.ts, serializers, UI button, and
registerComponentSerializers; use editor.getText() for agent context
- Settings drawer: flatten Advanced accordion, fix toggle clipping
(ml: 0), widen to 380px, use slotProps.paper (MUI v9)
Made-with: Cursor
- README.md +3 -3
- backend/package.json +1 -1
- backend/src/publisher/html-renderer.ts +161 -43
- backend/src/publisher/index.ts +4 -1
- backend/src/server.ts +1 -1
- docs/embed-studio.md +2 -2
- frontend/index.html +1 -1
- frontend/package.json +1 -1
- frontend/src/App.tsx +128 -97
- frontend/src/components/TableOfContents.tsx +235 -0
- frontend/src/editor/Editor.tsx +1 -4
- frontend/src/editor/components/index.ts +0 -1
- frontend/src/editor/components/serializers.ts +0 -41
- frontend/src/editor/frontmatter/FrontmatterHero.tsx +218 -163
- frontend/src/editor/frontmatter/SettingsDrawer.tsx +47 -63
- frontend/src/editor/frontmatter/seed-frontmatter.ts +1 -1
- frontend/src/export/serializers/index.ts +0 -133
- frontend/src/export/to-mdx.ts +0 -70
- frontend/src/hooks/useAgentChat.ts +1 -2
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: ✏️
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: blue
|
|
@@ -11,9 +11,9 @@ hf_oauth_scopes:
|
|
| 11 |
- manage-repos
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
Collaborative real-time article editor
|
| 17 |
|
| 18 |
## Features
|
| 19 |
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Science Article Template Editor
|
| 3 |
emoji: ✏️
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: blue
|
|
|
|
| 11 |
- manage-repos
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Science Article Template Editor
|
| 15 |
|
| 16 |
+
Collaborative real-time scientific article editor. Built with TipTap, Yjs, and Hocuspocus.
|
| 17 |
|
| 18 |
## Features
|
| 19 |
|
backend/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
"type": "module",
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "science-article-template-editor-backend",
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
"type": "module",
|
backend/src/publisher/html-renderer.ts
CHANGED
|
@@ -11,14 +11,23 @@ import { getServerExtensions } from "./extensions.js";
|
|
| 11 |
export interface PublishAuthor {
|
| 12 |
name: string;
|
| 13 |
url?: string;
|
|
|
|
| 14 |
affiliationNames: string[];
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
export interface PublishMeta {
|
| 18 |
title: string;
|
|
|
|
| 19 |
description: string;
|
| 20 |
authors: PublishAuthor[];
|
|
|
|
| 21 |
date: string;
|
|
|
|
| 22 |
ogImage?: string;
|
| 23 |
}
|
| 24 |
|
|
@@ -100,49 +109,112 @@ body {
|
|
| 100 |
align-items: start;
|
| 101 |
}
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
-
.article-
|
| 110 |
-
font-size:
|
| 111 |
-
font-weight:
|
| 112 |
-
|
| 113 |
-
line-height: 1.2;
|
| 114 |
-
margin: 0 0 0.5rem;
|
| 115 |
letter-spacing: -0.02em;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
-
.article-
|
| 119 |
-
color: var(--text-
|
| 120 |
-
font-
|
| 121 |
-
margin
|
|
|
|
|
|
|
| 122 |
line-height: 1.6;
|
| 123 |
}
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
-
.article-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
-
.article-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
color: var(--text-tertiary);
|
| 136 |
-
|
| 137 |
-
|
| 138 |
}
|
| 139 |
|
| 140 |
-
.article-
|
| 141 |
-
.article-header .author-affiliation::after { content: ")"; }
|
| 142 |
|
| 143 |
-
.article-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
/* Theme toggle */
|
|
@@ -381,6 +453,15 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
|
|
| 381 |
<div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
|
| 382 |
</aside>
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
<section class="content-grid">
|
| 385 |
<nav class="table-of-contents" aria-label="Table of Contents">
|
| 386 |
<div class="title">Table of Contents</div>
|
|
@@ -388,12 +469,6 @@ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; bo
|
|
| 388 |
</nav>
|
| 389 |
|
| 390 |
<article>
|
| 391 |
-
<header class="article-header">
|
| 392 |
-
<h1>${safeTitle}</h1>
|
| 393 |
-
${meta.authors.length > 0 ? `<div class="authors">${renderAuthors(meta.authors)}</div>` : ""}
|
| 394 |
-
${meta.date ? `<p class="date">${escapeHtml(formatDate(meta.date))}</p>` : ""}
|
| 395 |
-
</header>
|
| 396 |
-
|
| 397 |
<div class="tiptap">
|
| 398 |
${enrichedBody}
|
| 399 |
</div>
|
|
@@ -719,16 +794,59 @@ function escapeHtml(str: string): string {
|
|
| 719 |
.replace(/'/g, "'");
|
| 720 |
}
|
| 721 |
|
| 722 |
-
function
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
}
|
| 733 |
|
| 734 |
function formatDate(dateStr: string): string {
|
|
|
|
| 11 |
export interface PublishAuthor {
|
| 12 |
name: string;
|
| 13 |
url?: string;
|
| 14 |
+
affiliationIndices: number[];
|
| 15 |
affiliationNames: string[];
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export interface PublishAffiliation {
|
| 19 |
+
name: string;
|
| 20 |
+
url?: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
export interface PublishMeta {
|
| 24 |
title: string;
|
| 25 |
+
subtitle?: string;
|
| 26 |
description: string;
|
| 27 |
authors: PublishAuthor[];
|
| 28 |
+
affiliations: PublishAffiliation[];
|
| 29 |
date: string;
|
| 30 |
+
doi?: string;
|
| 31 |
ogImage?: string;
|
| 32 |
}
|
| 33 |
|
|
|
|
| 109 |
align-items: start;
|
| 110 |
}
|
| 111 |
|
| 112 |
+
/* Hero section */
|
| 113 |
+
.article-hero {
|
| 114 |
+
width: 100%;
|
| 115 |
+
padding: 64px 16px 16px;
|
| 116 |
+
text-align: center;
|
| 117 |
}
|
| 118 |
|
| 119 |
+
.article-hero h1 {
|
| 120 |
+
font-size: clamp(34px, 5vw, 54px);
|
| 121 |
+
font-weight: 800;
|
| 122 |
+
line-height: 1.12;
|
|
|
|
|
|
|
| 123 |
letter-spacing: -0.02em;
|
| 124 |
+
margin: 0 auto 8px;
|
| 125 |
+
max-width: 780px;
|
| 126 |
+
text-wrap: balance;
|
| 127 |
+
color: var(--text-heading);
|
| 128 |
}
|
| 129 |
|
| 130 |
+
.article-hero .hero-desc {
|
| 131 |
+
color: var(--text-tertiary);
|
| 132 |
+
font-style: italic;
|
| 133 |
+
margin: 0 auto 16px;
|
| 134 |
+
max-width: 55%;
|
| 135 |
+
font-size: 1.15em;
|
| 136 |
line-height: 1.6;
|
| 137 |
}
|
| 138 |
|
| 139 |
+
/* Meta bar */
|
| 140 |
+
.article-meta {
|
| 141 |
+
border-top: 1px solid var(--border);
|
| 142 |
+
border-bottom: 1px solid var(--border);
|
| 143 |
+
padding: 1rem 0;
|
| 144 |
+
font-size: 0.9rem;
|
| 145 |
}
|
| 146 |
|
| 147 |
+
.article-meta .meta-container {
|
| 148 |
+
max-width: 980px;
|
| 149 |
+
margin: 0 auto;
|
| 150 |
+
padding: 0 16px;
|
| 151 |
+
display: flex;
|
| 152 |
+
flex-direction: row;
|
| 153 |
+
justify-content: space-between;
|
| 154 |
+
gap: 8px;
|
| 155 |
+
flex-wrap: wrap;
|
| 156 |
+
row-gap: 12px;
|
| 157 |
}
|
| 158 |
|
| 159 |
+
.article-meta .meta-cell {
|
| 160 |
+
display: flex;
|
| 161 |
+
flex-direction: column;
|
| 162 |
+
gap: 8px;
|
| 163 |
+
max-width: 400px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.article-meta .meta-cell h3 {
|
| 167 |
+
margin: 0;
|
| 168 |
+
font-size: 12px;
|
| 169 |
+
font-weight: 400;
|
| 170 |
color: var(--text-tertiary);
|
| 171 |
+
text-transform: uppercase;
|
| 172 |
+
letter-spacing: 0.02em;
|
| 173 |
}
|
| 174 |
|
| 175 |
+
.article-meta .meta-cell p { margin: 0; }
|
|
|
|
| 176 |
|
| 177 |
+
.article-meta .authors-list {
|
| 178 |
+
margin: 0;
|
| 179 |
+
list-style: none;
|
| 180 |
+
padding-left: 0;
|
| 181 |
+
display: flex;
|
| 182 |
+
flex-wrap: wrap;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.article-meta .authors-list li {
|
| 186 |
+
white-space: nowrap;
|
| 187 |
+
padding: 0;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.article-meta .author-link {
|
| 191 |
+
color: var(--accent-light);
|
| 192 |
+
text-decoration: underline;
|
| 193 |
+
text-underline-offset: 2px;
|
| 194 |
+
text-decoration-thickness: 0.06em;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.article-meta .author-link:hover {
|
| 198 |
+
color: var(--text-primary);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.article-meta .affiliations-list {
|
| 202 |
+
margin: 0;
|
| 203 |
+
padding-left: 1.25em;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.article-meta .affiliations-list li { margin: 0; }
|
| 207 |
+
|
| 208 |
+
.article-meta .doi-link {
|
| 209 |
+
color: var(--accent-light);
|
| 210 |
+
text-decoration: underline;
|
| 211 |
+
text-underline-offset: 2px;
|
| 212 |
+
font-family: monospace;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
@media (max-width: 768px) {
|
| 216 |
+
.article-hero .hero-desc { max-width: 90%; }
|
| 217 |
+
.article-meta .meta-container { flex-direction: column; }
|
| 218 |
}
|
| 219 |
|
| 220 |
/* Theme toggle */
|
|
|
|
| 453 |
<div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
|
| 454 |
</aside>
|
| 455 |
|
| 456 |
+
<!-- Hero -->
|
| 457 |
+
<section class="article-hero">
|
| 458 |
+
<h1>${safeTitle}</h1>
|
| 459 |
+
${meta.subtitle ? `<p class="hero-desc">${escapeHtml(meta.subtitle)}</p>` : ""}
|
| 460 |
+
</section>
|
| 461 |
+
|
| 462 |
+
<!-- Meta bar -->
|
| 463 |
+
${renderMetaBar(meta)}
|
| 464 |
+
|
| 465 |
<section class="content-grid">
|
| 466 |
<nav class="table-of-contents" aria-label="Table of Contents">
|
| 467 |
<div class="title">Table of Contents</div>
|
|
|
|
| 469 |
</nav>
|
| 470 |
|
| 471 |
<article>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
<div class="tiptap">
|
| 473 |
${enrichedBody}
|
| 474 |
</div>
|
|
|
|
| 794 |
.replace(/'/g, "'");
|
| 795 |
}
|
| 796 |
|
| 797 |
+
function renderMetaBar(meta: PublishMeta): string {
|
| 798 |
+
const hasContent = meta.authors.length > 0 || meta.date || meta.doi;
|
| 799 |
+
if (!hasContent) return "";
|
| 800 |
+
|
| 801 |
+
const multipleAff = meta.affiliations.length > 1;
|
| 802 |
+
const cells: string[] = [];
|
| 803 |
+
|
| 804 |
+
// Authors cell
|
| 805 |
+
if (meta.authors.length > 0) {
|
| 806 |
+
const items = meta.authors.map((a, i) => {
|
| 807 |
+
const name = a.url
|
| 808 |
+
? `<a href="${escapeHtml(a.url)}" class="author-link" target="_blank" rel="noopener">${escapeHtml(a.name)}</a>`
|
| 809 |
+
: escapeHtml(a.name);
|
| 810 |
+
const sup = multipleAff && a.affiliationIndices.length > 0
|
| 811 |
+
? `<sup>${a.affiliationIndices.join(",")}</sup>`
|
| 812 |
+
: "";
|
| 813 |
+
const sep = i < meta.authors.length - 1 ? ", " : "";
|
| 814 |
+
return `<li>${name}${sup}${sep}</li>`;
|
| 815 |
+
}).join("");
|
| 816 |
+
cells.push(`<div class="meta-cell"><h3>Authors</h3><ul class="authors-list">${items}</ul></div>`);
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
// Affiliations cell
|
| 820 |
+
if (meta.affiliations.length > 0) {
|
| 821 |
+
let affContent: string;
|
| 822 |
+
if (multipleAff) {
|
| 823 |
+
const items = meta.affiliations.map((aff) => {
|
| 824 |
+
const name = aff.url
|
| 825 |
+
? `<a href="${escapeHtml(aff.url)}" target="_blank" rel="noopener">${escapeHtml(aff.name)}</a>`
|
| 826 |
+
: escapeHtml(aff.name);
|
| 827 |
+
return `<li>${name}</li>`;
|
| 828 |
+
}).join("");
|
| 829 |
+
affContent = `<ol class="affiliations-list">${items}</ol>`;
|
| 830 |
+
} else {
|
| 831 |
+
const aff = meta.affiliations[0];
|
| 832 |
+
affContent = aff.url
|
| 833 |
+
? `<p><a href="${escapeHtml(aff.url)}" target="_blank" rel="noopener">${escapeHtml(aff.name)}</a></p>`
|
| 834 |
+
: `<p>${escapeHtml(aff.name)}</p>`;
|
| 835 |
+
}
|
| 836 |
+
cells.push(`<div class="meta-cell"><h3>Affiliations</h3>${affContent}</div>`);
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
// Published cell
|
| 840 |
+
if (meta.date) {
|
| 841 |
+
cells.push(`<div class="meta-cell"><h3>Published</h3><p>${escapeHtml(formatDate(meta.date))}</p></div>`);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
// DOI cell
|
| 845 |
+
if (meta.doi) {
|
| 846 |
+
cells.push(`<div class="meta-cell"><h3>DOI</h3><p><a href="https://doi.org/${escapeHtml(meta.doi)}" class="doi-link" target="_blank" rel="noopener">${escapeHtml(meta.doi)}</a></p></div>`);
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
return `<header class="article-meta"><div class="meta-container">${cells.join("")}</div></header>`;
|
| 850 |
}
|
| 851 |
|
| 852 |
function formatDate(dateStr: string): string {
|
backend/src/publisher/index.ts
CHANGED
|
@@ -152,18 +152,21 @@ export async function publishDocument(docName: string, token?: string): Promise<
|
|
| 152 |
// Extract content
|
| 153 |
const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
|
| 154 |
|
| 155 |
-
// Build metadata - authors come from Y.Array, date from "published" scalar
|
| 156 |
const meta: PublishMeta = {
|
| 157 |
title: (frontmatter.title as string) || docName,
|
|
|
|
| 158 |
description: (frontmatter.description as string) || "",
|
| 159 |
authors: authors.map((a) => ({
|
| 160 |
name: a.name,
|
| 161 |
url: a.url,
|
|
|
|
| 162 |
affiliationNames: (a.affiliations || [])
|
| 163 |
.map((idx) => affiliations[idx - 1]?.name)
|
| 164 |
.filter(Boolean) as string[],
|
| 165 |
})),
|
|
|
|
| 166 |
date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
|
|
|
|
| 167 |
};
|
| 168 |
|
| 169 |
const css = loadCSS();
|
|
|
|
| 152 |
// Extract content
|
| 153 |
const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
|
| 154 |
|
|
|
|
| 155 |
const meta: PublishMeta = {
|
| 156 |
title: (frontmatter.title as string) || docName,
|
| 157 |
+
subtitle: (frontmatter.subtitle as string) || undefined,
|
| 158 |
description: (frontmatter.description as string) || "",
|
| 159 |
authors: authors.map((a) => ({
|
| 160 |
name: a.name,
|
| 161 |
url: a.url,
|
| 162 |
+
affiliationIndices: a.affiliations || [],
|
| 163 |
affiliationNames: (a.affiliations || [])
|
| 164 |
.map((idx) => affiliations[idx - 1]?.name)
|
| 165 |
.filter(Boolean) as string[],
|
| 166 |
})),
|
| 167 |
+
affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
|
| 168 |
date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
|
| 169 |
+
doi: (frontmatter.doi as string) || undefined,
|
| 170 |
};
|
| 171 |
|
| 172 |
const css = loadCSS();
|
backend/src/server.ts
CHANGED
|
@@ -321,7 +321,7 @@ function sendLoginPage(res: express.Response) {
|
|
| 321 |
<head>
|
| 322 |
<meta charset="utf-8" />
|
| 323 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 324 |
-
<title>
|
| 325 |
<style>
|
| 326 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 327 |
body { display: flex; align-items: center; justify-content: center; height: 100vh;
|
|
|
|
| 321 |
<head>
|
| 322 |
<meta charset="utf-8" />
|
| 323 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 324 |
+
<title>Science Article Template Editor</title>
|
| 325 |
<style>
|
| 326 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 327 |
body { display: flex; align-items: center; justify-content: center; height: 100vh;
|
docs/embed-studio.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
| 2 |
|
| 3 |
## Overview
|
| 4 |
|
| 5 |
-
The Embed Studio is a dedicated UI mode within the
|
| 6 |
|
| 7 |
## Context
|
| 8 |
|
| 9 |
The research-article-template uses `<HtmlEmbed>` components to embed self-contained D3.js charts into articles. These are `.html` files in `app/src/content/embeds/` with strict conventions (scoped CSS, IIFE scripts, ColorPalettes, responsive, etc.) documented in `.ai/skills/create-html-embed/directives.md`.
|
| 10 |
|
| 11 |
-
In the
|
| 12 |
|
| 13 |
## Storage
|
| 14 |
|
|
|
|
| 2 |
|
| 3 |
## Overview
|
| 4 |
|
| 5 |
+
The Embed Studio is a dedicated UI mode within the editor for creating, editing, and previewing HTML embed visualizations (D3.js charts). It isolates the dataviz workflow from the article editing flow, providing a focused chat + preview experience similar to the standalone [dataviz-agent-space](https://huggingface.co/spaces/tfrere/dataviz-agent-space).
|
| 6 |
|
| 7 |
## Context
|
| 8 |
|
| 9 |
The research-article-template uses `<HtmlEmbed>` components to embed self-contained D3.js charts into articles. These are `.html` files in `app/src/content/embeds/` with strict conventions (scoped CSS, IIFE scripts, ColorPalettes, responsive, etc.) documented in `.ai/skills/create-html-embed/directives.md`.
|
| 10 |
|
| 11 |
+
In the editor, users need to create and iterate on these charts without leaving the editor. The Embed Studio solves this by providing a dedicated panel with an AI assistant specialized in D3 chart generation.
|
| 12 |
|
| 13 |
## Storage
|
| 14 |
|
frontend/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<div id="root"></div>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Science Article Template Editor</title>
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<div id="root"></div>
|
frontend/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
"type": "module",
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "science-article-template-editor-frontend",
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
"type": "module",
|
frontend/src/App.tsx
CHANGED
|
@@ -5,16 +5,16 @@ import type * as Y from "yjs";
|
|
| 5 |
import {
|
| 6 |
Box,
|
| 7 |
Chip,
|
| 8 |
-
Collapse,
|
| 9 |
-
Paper,
|
| 10 |
IconButton,
|
| 11 |
Tooltip,
|
|
|
|
| 12 |
} from "@mui/material";
|
| 13 |
import UndoIcon from "@mui/icons-material/Undo";
|
| 14 |
import RedoIcon from "@mui/icons-material/Redo";
|
| 15 |
-
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
| 16 |
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
| 17 |
import PublishOutlinedIcon from "@mui/icons-material/PublishOutlined";
|
|
|
|
|
|
|
| 18 |
import {
|
| 19 |
Button,
|
| 20 |
Dialog,
|
|
@@ -23,13 +23,14 @@ import {
|
|
| 23 |
DialogContentText,
|
| 24 |
DialogActions,
|
| 25 |
CircularProgress,
|
|
|
|
| 26 |
} from "@mui/material";
|
| 27 |
import { Editor } from "./editor/Editor";
|
| 28 |
import { CommentsSidebar } from "./components/CommentsSidebar";
|
| 29 |
import { CommentDialog } from "./components/CommentDialog";
|
| 30 |
import { ChatPanel } from "./components/ChatPanel";
|
|
|
|
| 31 |
import { useAgentChat } from "./hooks/useAgentChat";
|
| 32 |
-
import { toMdx } from "./export/to-mdx";
|
| 33 |
import type { CommentStore } from "./editor/comments";
|
| 34 |
import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
|
| 35 |
import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
|
|
@@ -64,8 +65,6 @@ export default function App() {
|
|
| 64 |
const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
|
| 65 |
const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
|
| 66 |
const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
|
| 67 |
-
const [mdxOutput, setMdxOutput] = useState("");
|
| 68 |
-
const [showExport, setShowExport] = useState(false);
|
| 69 |
const [commentDialogOpen, setCommentDialogOpen] = useState(false);
|
| 70 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 71 |
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
|
@@ -74,6 +73,7 @@ export default function App() {
|
|
| 74 |
const selectionRange = useRef<{ from: number; to: number } | null>(null);
|
| 75 |
const [hasSelection, setHasSelection] = useState(false);
|
| 76 |
const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
|
|
|
|
| 77 |
|
| 78 |
const agentChat = useAgentChat({ editor: editorInstance, undoManager, frontmatterStore });
|
| 79 |
|
|
@@ -97,14 +97,6 @@ export default function App() {
|
|
| 97 |
setEditorInstance(editor);
|
| 98 |
}, []);
|
| 99 |
|
| 100 |
-
const handleExport = () => {
|
| 101 |
-
if (!editorRef.current) return;
|
| 102 |
-
const json = editorRef.current.getJSON();
|
| 103 |
-
const fm = frontmatterStore?.getAll();
|
| 104 |
-
setMdxOutput(toMdx(json, fm));
|
| 105 |
-
setShowExport((v) => !v);
|
| 106 |
-
};
|
| 107 |
-
|
| 108 |
const onCommentStoreReady = useCallback((store: CommentStore) => {
|
| 109 |
setCommentStore(store);
|
| 110 |
}, []);
|
|
@@ -221,11 +213,6 @@ export default function App() {
|
|
| 221 |
<SettingsOutlinedIcon sx={{ fontSize: 18 }} />
|
| 222 |
</IconButton>
|
| 223 |
</Tooltip>
|
| 224 |
-
<Tooltip title="Export MDX" arrow>
|
| 225 |
-
<IconButton size="small" onClick={handleExport} sx={{ color: "text.disabled" }}>
|
| 226 |
-
<FileDownloadOutlinedIcon sx={{ fontSize: 18 }} />
|
| 227 |
-
</IconButton>
|
| 228 |
-
</Tooltip>
|
| 229 |
<Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
|
| 230 |
<Tooltip title="Publish article" arrow>
|
| 231 |
<IconButton
|
|
@@ -250,100 +237,144 @@ export default function App() {
|
|
| 250 |
/>
|
| 251 |
</Box>
|
| 252 |
|
| 253 |
-
{/*
|
| 254 |
<Box
|
|
|
|
| 255 |
sx={{
|
| 256 |
flex: 1,
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
minHeight: 0,
|
| 260 |
-
overflow: "hidden",
|
| 261 |
}}
|
| 262 |
>
|
| 263 |
-
{/* Chat - fixed, own scroll */}
|
| 264 |
-
<Box
|
| 265 |
-
sx={{
|
| 266 |
-
borderRight: "1px solid",
|
| 267 |
-
borderColor: "divider",
|
| 268 |
-
overflow: "hidden",
|
| 269 |
-
display: "flex",
|
| 270 |
-
}}
|
| 271 |
-
>
|
| 272 |
-
<ChatPanel
|
| 273 |
-
messages={agentChat.messages}
|
| 274 |
-
isLoading={agentChat.isLoading}
|
| 275 |
-
error={agentChat.error}
|
| 276 |
-
input={agentChat.input}
|
| 277 |
-
hasSelection={hasSelection}
|
| 278 |
-
onSend={agentChat.sendMessage}
|
| 279 |
-
onQuickAction={agentChat.sendQuickAction}
|
| 280 |
-
onSetInput={agentChat.setInput}
|
| 281 |
-
onStop={agentChat.stop}
|
| 282 |
-
onNewChat={() => window.location.reload()}
|
| 283 |
-
/>
|
| 284 |
-
</Box>
|
| 285 |
-
|
| 286 |
-
{/* Editor - scrolls independently */}
|
| 287 |
<Box
|
| 288 |
-
ref={editorContainerCallback}
|
| 289 |
sx={{
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
| 293 |
}}
|
| 294 |
>
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
/
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
{
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
| 328 |
</Box>
|
|
|
|
| 329 |
|
| 330 |
-
|
|
|
|
| 331 |
<Box
|
| 332 |
sx={{
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
}}
|
| 338 |
>
|
| 339 |
-
<
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
</Box>
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
<CommentDialog
|
| 349 |
open={commentDialogOpen}
|
|
|
|
| 5 |
import {
|
| 6 |
Box,
|
| 7 |
Chip,
|
|
|
|
|
|
|
| 8 |
IconButton,
|
| 9 |
Tooltip,
|
| 10 |
+
Typography,
|
| 11 |
} from "@mui/material";
|
| 12 |
import UndoIcon from "@mui/icons-material/Undo";
|
| 13 |
import RedoIcon from "@mui/icons-material/Redo";
|
|
|
|
| 14 |
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
| 15 |
import PublishOutlinedIcon from "@mui/icons-material/PublishOutlined";
|
| 16 |
+
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutlined";
|
| 17 |
+
import CloseIcon from "@mui/icons-material/Close";
|
| 18 |
import {
|
| 19 |
Button,
|
| 20 |
Dialog,
|
|
|
|
| 23 |
DialogContentText,
|
| 24 |
DialogActions,
|
| 25 |
CircularProgress,
|
| 26 |
+
Badge,
|
| 27 |
} from "@mui/material";
|
| 28 |
import { Editor } from "./editor/Editor";
|
| 29 |
import { CommentsSidebar } from "./components/CommentsSidebar";
|
| 30 |
import { CommentDialog } from "./components/CommentDialog";
|
| 31 |
import { ChatPanel } from "./components/ChatPanel";
|
| 32 |
+
import { TableOfContents } from "./components/TableOfContents";
|
| 33 |
import { useAgentChat } from "./hooks/useAgentChat";
|
|
|
|
| 34 |
import type { CommentStore } from "./editor/comments";
|
| 35 |
import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
|
| 36 |
import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
|
|
|
|
| 65 |
const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
|
| 66 |
const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
|
| 67 |
const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
|
|
|
|
|
|
|
| 68 |
const [commentDialogOpen, setCommentDialogOpen] = useState(false);
|
| 69 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 70 |
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
|
|
|
| 73 |
const selectionRange = useRef<{ from: number; to: number } | null>(null);
|
| 74 |
const [hasSelection, setHasSelection] = useState(false);
|
| 75 |
const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
|
| 76 |
+
const [chatOpen, setChatOpen] = useState(false);
|
| 77 |
|
| 78 |
const agentChat = useAgentChat({ editor: editorInstance, undoManager, frontmatterStore });
|
| 79 |
|
|
|
|
| 97 |
setEditorInstance(editor);
|
| 98 |
}, []);
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
const onCommentStoreReady = useCallback((store: CommentStore) => {
|
| 101 |
setCommentStore(store);
|
| 102 |
}, []);
|
|
|
|
| 213 |
<SettingsOutlinedIcon sx={{ fontSize: 18 }} />
|
| 214 |
</IconButton>
|
| 215 |
</Tooltip>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
<Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
|
| 217 |
<Tooltip title="Publish article" arrow>
|
| 218 |
<IconButton
|
|
|
|
| 237 |
/>
|
| 238 |
</Box>
|
| 239 |
|
| 240 |
+
{/* Single scroll container */}
|
| 241 |
<Box
|
| 242 |
+
ref={editorContainerCallback}
|
| 243 |
sx={{
|
| 244 |
flex: 1,
|
| 245 |
+
overflowY: "auto",
|
| 246 |
+
overflowX: "hidden",
|
|
|
|
|
|
|
| 247 |
}}
|
| 248 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
<Box
|
|
|
|
| 250 |
sx={{
|
| 251 |
+
display: "grid",
|
| 252 |
+
gridTemplateColumns: "200px 1fr 240px",
|
| 253 |
+
gridTemplateRows: "auto 1fr",
|
| 254 |
+
minHeight: "100%",
|
| 255 |
}}
|
| 256 |
>
|
| 257 |
+
{/* Hero - center column only, row 1 */}
|
| 258 |
+
<Box sx={{ gridColumn: 2, gridRow: 1, pt: 4 }}>
|
| 259 |
+
<FrontmatterHero store={frontmatterStore} />
|
| 260 |
+
</Box>
|
| 261 |
+
|
| 262 |
+
{/* TOC - left column, row 2, sticky */}
|
| 263 |
+
<Box sx={{ gridColumn: 1, gridRow: 2 }}>
|
| 264 |
+
<Box sx={{ position: "sticky", top: 16 }}>
|
| 265 |
+
<TableOfContents editor={editorInstance} />
|
| 266 |
+
</Box>
|
| 267 |
+
</Box>
|
| 268 |
+
|
| 269 |
+
{/* Editor - center column, row 2 */}
|
| 270 |
+
<Box sx={{ gridColumn: 2, gridRow: 2, pb: 4 }}>
|
| 271 |
+
<Editor
|
| 272 |
+
docName={docName}
|
| 273 |
+
user={user}
|
| 274 |
+
editorRef={editorRef}
|
| 275 |
+
onCommentStoreReady={onCommentStoreReady}
|
| 276 |
+
onFrontmatterStoreReady={onFrontmatterStoreReady}
|
| 277 |
+
onSettingsMapReady={onSettingsMapReady}
|
| 278 |
+
onEditorReady={onEditorReady}
|
| 279 |
+
onUndoManagerReady={setUndoManager}
|
| 280 |
+
onAddComment={handleAddComment}
|
| 281 |
+
/>
|
| 282 |
+
</Box>
|
| 283 |
+
|
| 284 |
+
{/* Comments - right column, row 2 */}
|
| 285 |
+
<Box sx={{ gridColumn: 3, gridRow: 2, pl: 2, py: 2 }}>
|
| 286 |
+
<CommentsSidebar
|
| 287 |
+
editor={editorInstance}
|
| 288 |
+
commentStore={commentStore}
|
| 289 |
+
user={user}
|
| 290 |
+
editorContainer={containerEl}
|
| 291 |
+
/>
|
| 292 |
+
</Box>
|
| 293 |
</Box>
|
| 294 |
+
</Box>
|
| 295 |
|
| 296 |
+
{/* Floating chat - bottom left */}
|
| 297 |
+
{chatOpen ? (
|
| 298 |
<Box
|
| 299 |
sx={{
|
| 300 |
+
position: "fixed",
|
| 301 |
+
bottom: 16,
|
| 302 |
+
left: 16,
|
| 303 |
+
width: 360,
|
| 304 |
+
height: 520,
|
| 305 |
+
maxHeight: "calc(100vh - 80px)",
|
| 306 |
+
borderRadius: 3,
|
| 307 |
+
overflow: "hidden",
|
| 308 |
+
display: "flex",
|
| 309 |
+
flexDirection: "column",
|
| 310 |
+
bgcolor: "background.paper",
|
| 311 |
+
border: "1px solid",
|
| 312 |
+
borderColor: "divider",
|
| 313 |
+
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
| 314 |
+
zIndex: 1300,
|
| 315 |
}}
|
| 316 |
>
|
| 317 |
+
<Box
|
| 318 |
+
sx={{
|
| 319 |
+
display: "flex",
|
| 320 |
+
alignItems: "center",
|
| 321 |
+
justifyContent: "space-between",
|
| 322 |
+
px: 1.5,
|
| 323 |
+
py: 0.75,
|
| 324 |
+
borderBottom: "1px solid",
|
| 325 |
+
borderColor: "divider",
|
| 326 |
+
flexShrink: 0,
|
| 327 |
+
}}
|
| 328 |
+
>
|
| 329 |
+
<Typography variant="caption" sx={{ fontWeight: 600, color: "text.secondary", fontSize: "0.7rem" }}>
|
| 330 |
+
AI Assistant
|
| 331 |
+
</Typography>
|
| 332 |
+
<IconButton size="small" onClick={() => setChatOpen(false)} sx={{ color: "text.disabled" }}>
|
| 333 |
+
<CloseIcon sx={{ fontSize: 16 }} />
|
| 334 |
+
</IconButton>
|
| 335 |
+
</Box>
|
| 336 |
+
<Box sx={{ flex: 1, overflow: "hidden", display: "flex" }}>
|
| 337 |
+
<ChatPanel
|
| 338 |
+
messages={agentChat.messages}
|
| 339 |
+
isLoading={agentChat.isLoading}
|
| 340 |
+
error={agentChat.error}
|
| 341 |
+
input={agentChat.input}
|
| 342 |
+
hasSelection={hasSelection}
|
| 343 |
+
onSend={agentChat.sendMessage}
|
| 344 |
+
onQuickAction={agentChat.sendQuickAction}
|
| 345 |
+
onSetInput={agentChat.setInput}
|
| 346 |
+
onStop={agentChat.stop}
|
| 347 |
+
onNewChat={() => window.location.reload()}
|
| 348 |
+
/>
|
| 349 |
+
</Box>
|
| 350 |
</Box>
|
| 351 |
+
) : (
|
| 352 |
+
<Tooltip title="AI Assistant" placement="right" arrow>
|
| 353 |
+
<IconButton
|
| 354 |
+
onClick={() => setChatOpen(true)}
|
| 355 |
+
sx={{
|
| 356 |
+
position: "fixed",
|
| 357 |
+
bottom: 20,
|
| 358 |
+
left: 20,
|
| 359 |
+
width: 48,
|
| 360 |
+
height: 48,
|
| 361 |
+
bgcolor: "primary.main",
|
| 362 |
+
color: "primary.contrastText",
|
| 363 |
+
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
|
| 364 |
+
zIndex: 1300,
|
| 365 |
+
"&:hover": { bgcolor: "primary.dark" },
|
| 366 |
+
}}
|
| 367 |
+
>
|
| 368 |
+
<Badge
|
| 369 |
+
color="error"
|
| 370 |
+
variant="dot"
|
| 371 |
+
invisible={!agentChat.isLoading}
|
| 372 |
+
>
|
| 373 |
+
<ChatBubbleOutlineIcon sx={{ fontSize: 22 }} />
|
| 374 |
+
</Badge>
|
| 375 |
+
</IconButton>
|
| 376 |
+
</Tooltip>
|
| 377 |
+
)}
|
| 378 |
|
| 379 |
<CommentDialog
|
| 380 |
open={commentDialogOpen}
|
frontend/src/components/TableOfContents.tsx
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
| 2 |
+
import { Box, Typography } from "@mui/material";
|
| 3 |
+
import type { Editor } from "@tiptap/core";
|
| 4 |
+
|
| 5 |
+
const SCROLL_OFFSET_PX = 80;
|
| 6 |
+
|
| 7 |
+
interface TocItem {
|
| 8 |
+
id: string;
|
| 9 |
+
text: string;
|
| 10 |
+
level: number;
|
| 11 |
+
pos: number;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface TocNode extends TocItem {
|
| 15 |
+
children: TocNode[];
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function extractHeadings(editor: Editor): TocItem[] {
|
| 19 |
+
const items: TocItem[] = [];
|
| 20 |
+
editor.state.doc.descendants((node, pos) => {
|
| 21 |
+
if (node.type.name === "heading") {
|
| 22 |
+
const level = node.attrs.level as number;
|
| 23 |
+
const text = node.textContent;
|
| 24 |
+
if (text.trim()) {
|
| 25 |
+
items.push({ id: `heading-${pos}`, text, level, pos });
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
return items;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function buildTree(headings: TocItem[]): TocNode[] {
|
| 33 |
+
const root: TocNode[] = [];
|
| 34 |
+
const stack: TocNode[] = [];
|
| 35 |
+
|
| 36 |
+
for (const h of headings) {
|
| 37 |
+
const node: TocNode = { ...h, children: [] };
|
| 38 |
+
|
| 39 |
+
while (stack.length > 0 && stack[stack.length - 1].level >= h.level) {
|
| 40 |
+
stack.pop();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (stack.length === 0) {
|
| 44 |
+
root.push(node);
|
| 45 |
+
} else {
|
| 46 |
+
stack[stack.length - 1].children.push(node);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
stack.push(node);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return root;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
interface TableOfContentsProps {
|
| 56 |
+
editor: Editor | null;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export function TableOfContents({ editor }: TableOfContentsProps) {
|
| 60 |
+
const [headings, setHeadings] = useState<TocItem[]>([]);
|
| 61 |
+
const [activePos, setActivePos] = useState<number | null>(null);
|
| 62 |
+
const rafRef = useRef(0);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
if (!editor) return;
|
| 66 |
+
const update = () => setHeadings(extractHeadings(editor));
|
| 67 |
+
update();
|
| 68 |
+
editor.on("update", update);
|
| 69 |
+
return () => { editor.off("update", update); };
|
| 70 |
+
}, [editor]);
|
| 71 |
+
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
if (!editor || headings.length === 0) return;
|
| 74 |
+
|
| 75 |
+
const detect = () => {
|
| 76 |
+
const domHeadings = Array.from(
|
| 77 |
+
editor.view.dom.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
| 78 |
+
).filter((el) => (el as HTMLElement).textContent?.trim());
|
| 79 |
+
|
| 80 |
+
let activeIdx = -1;
|
| 81 |
+
for (let i = domHeadings.length - 1; i >= 0; i--) {
|
| 82 |
+
if (domHeadings[i].getBoundingClientRect().top <= SCROLL_OFFSET_PX) {
|
| 83 |
+
activeIdx = i;
|
| 84 |
+
break;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
setActivePos(activeIdx >= 0 && activeIdx < headings.length ? headings[activeIdx].pos : null);
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const onScroll = () => {
|
| 91 |
+
cancelAnimationFrame(rafRef.current);
|
| 92 |
+
rafRef.current = requestAnimationFrame(detect);
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
detect();
|
| 96 |
+
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
| 97 |
+
return () => {
|
| 98 |
+
document.removeEventListener("scroll", onScroll, { capture: true });
|
| 99 |
+
cancelAnimationFrame(rafRef.current);
|
| 100 |
+
};
|
| 101 |
+
}, [editor, headings]);
|
| 102 |
+
|
| 103 |
+
const tree = useMemo(() => buildTree(headings), [headings]);
|
| 104 |
+
|
| 105 |
+
const expandedPositions = useMemo(() => {
|
| 106 |
+
const set = new Set<number>();
|
| 107 |
+
if (activePos == null) return set;
|
| 108 |
+
|
| 109 |
+
const walk = (nodes: TocNode[], ancestors: number[]): boolean => {
|
| 110 |
+
for (const node of nodes) {
|
| 111 |
+
if (node.pos === activePos) {
|
| 112 |
+
ancestors.forEach((p) => set.add(p));
|
| 113 |
+
set.add(node.pos);
|
| 114 |
+
return true;
|
| 115 |
+
}
|
| 116 |
+
if (node.children.length > 0 && walk(node.children, [...ancestors, node.pos])) {
|
| 117 |
+
return true;
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
return false;
|
| 121 |
+
};
|
| 122 |
+
walk(tree, []);
|
| 123 |
+
return set;
|
| 124 |
+
}, [tree, activePos]);
|
| 125 |
+
|
| 126 |
+
const scrollTo = useCallback((pos: number) => {
|
| 127 |
+
if (!editor) return;
|
| 128 |
+
editor.chain().focus().setTextSelection(pos).run();
|
| 129 |
+
const dom = editor.view.domAtPos(pos);
|
| 130 |
+
const el = dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement;
|
| 131 |
+
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 132 |
+
}, [editor]);
|
| 133 |
+
|
| 134 |
+
if (!editor || headings.length === 0) {
|
| 135 |
+
return (
|
| 136 |
+
<Box sx={{ px: 2, pt: 3 }}>
|
| 137 |
+
<Typography variant="caption" sx={{ color: "text.disabled", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem" }}>
|
| 138 |
+
Table of contents
|
| 139 |
+
</Typography>
|
| 140 |
+
<Typography variant="caption" sx={{ color: "text.disabled", display: "block", mt: 1, fontSize: "0.7rem" }}>
|
| 141 |
+
Headings will appear here as you write.
|
| 142 |
+
</Typography>
|
| 143 |
+
</Box>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const renderList = (nodes: TocNode[], isRoot: boolean) => (
|
| 148 |
+
<Box
|
| 149 |
+
component="ul"
|
| 150 |
+
sx={{
|
| 151 |
+
m: 0,
|
| 152 |
+
mb: isRoot ? 0 : "6px",
|
| 153 |
+
pl: isRoot ? 0 : "1em",
|
| 154 |
+
listStyle: "none",
|
| 155 |
+
}}
|
| 156 |
+
>
|
| 157 |
+
{nodes.map((node) => {
|
| 158 |
+
const isExpanded = expandedPositions.has(node.pos);
|
| 159 |
+
const hasChildren = node.children.length > 0;
|
| 160 |
+
|
| 161 |
+
return (
|
| 162 |
+
<Box component="li" key={node.id} sx={{ my: "0.25em" }}>
|
| 163 |
+
<Box
|
| 164 |
+
component="a"
|
| 165 |
+
onClick={(e: React.MouseEvent) => {
|
| 166 |
+
e.preventDefault();
|
| 167 |
+
scrollTo(node.pos);
|
| 168 |
+
}}
|
| 169 |
+
sx={{
|
| 170 |
+
cursor: "pointer",
|
| 171 |
+
fontSize: "13px",
|
| 172 |
+
lineHeight: 1.5,
|
| 173 |
+
fontWeight: isRoot ? 700 : 400,
|
| 174 |
+
color: "text.secondary",
|
| 175 |
+
textDecoration: activePos === node.pos ? "underline" : "none",
|
| 176 |
+
textUnderlineOffset: "3px",
|
| 177 |
+
transition: "color 120ms ease",
|
| 178 |
+
"&:hover": {
|
| 179 |
+
textDecoration: "underline solid",
|
| 180 |
+
textDecorationColor: "text.disabled",
|
| 181 |
+
},
|
| 182 |
+
}}
|
| 183 |
+
>
|
| 184 |
+
{node.text}
|
| 185 |
+
</Box>
|
| 186 |
+
{hasChildren && (
|
| 187 |
+
<Box
|
| 188 |
+
sx={{
|
| 189 |
+
overflow: "hidden",
|
| 190 |
+
maxHeight: isExpanded ? 500 : 0,
|
| 191 |
+
opacity: isExpanded ? 1 : 0,
|
| 192 |
+
transition: "max-height 200ms ease, opacity 200ms ease",
|
| 193 |
+
}}
|
| 194 |
+
>
|
| 195 |
+
{renderList(node.children, false)}
|
| 196 |
+
</Box>
|
| 197 |
+
)}
|
| 198 |
+
</Box>
|
| 199 |
+
);
|
| 200 |
+
})}
|
| 201 |
+
</Box>
|
| 202 |
+
);
|
| 203 |
+
|
| 204 |
+
return (
|
| 205 |
+
<Box sx={{ px: 1, pt: 3 }}>
|
| 206 |
+
<Typography
|
| 207 |
+
variant="caption"
|
| 208 |
+
sx={{
|
| 209 |
+
color: "text.disabled",
|
| 210 |
+
fontWeight: 600,
|
| 211 |
+
textTransform: "uppercase",
|
| 212 |
+
letterSpacing: "0.05em",
|
| 213 |
+
fontSize: "0.65rem",
|
| 214 |
+
pl: "16px",
|
| 215 |
+
mb: 1,
|
| 216 |
+
display: "block",
|
| 217 |
+
}}
|
| 218 |
+
>
|
| 219 |
+
Table of contents
|
| 220 |
+
</Typography>
|
| 221 |
+
|
| 222 |
+
<Box
|
| 223 |
+
component="nav"
|
| 224 |
+
sx={{
|
| 225 |
+
borderLeft: "1px solid",
|
| 226 |
+
borderColor: "divider",
|
| 227 |
+
pl: "16px",
|
| 228 |
+
fontSize: "13px",
|
| 229 |
+
}}
|
| 230 |
+
>
|
| 231 |
+
{renderList(tree, true)}
|
| 232 |
+
</Box>
|
| 233 |
+
</Box>
|
| 234 |
+
);
|
| 235 |
+
}
|
frontend/src/editor/Editor.tsx
CHANGED
|
@@ -33,13 +33,10 @@ import { CitationPanel } from "./CitationPanel";
|
|
| 33 |
import { createCommentStore, CommentStore } from "./comments";
|
| 34 |
import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
|
| 35 |
import { seedFrontmatter } from "./frontmatter/seed-frontmatter";
|
| 36 |
-
import { COMPONENTS, createWrapperExtension, createAtomicExtension
|
| 37 |
import { uploadImage } from "./upload";
|
| 38 |
import { DEFAULT_CONTENT, SEED_CITATIONS } from "./default-content";
|
| 39 |
|
| 40 |
-
// Auto-register MDX serializers for all custom components (once)
|
| 41 |
-
registerComponentSerializers();
|
| 42 |
-
|
| 43 |
const lowlight = createLowlight(all);
|
| 44 |
|
| 45 |
interface EditorProps {
|
|
|
|
| 33 |
import { createCommentStore, CommentStore } from "./comments";
|
| 34 |
import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
|
| 35 |
import { seedFrontmatter } from "./frontmatter/seed-frontmatter";
|
| 36 |
+
import { COMPONENTS, createWrapperExtension, createAtomicExtension } from "./components";
|
| 37 |
import { uploadImage } from "./upload";
|
| 38 |
import { DEFAULT_CONTENT, SEED_CITATIONS } from "./default-content";
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
const lowlight = createLowlight(all);
|
| 41 |
|
| 42 |
interface EditorProps {
|
frontend/src/editor/components/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
export { COMPONENTS } from "./registry";
|
| 2 |
export type { ComponentDef, ComponentField } from "./registry";
|
| 3 |
export { createWrapperExtension, createAtomicExtension } from "./factory";
|
| 4 |
-
export { registerComponentSerializers } from "./serializers";
|
| 5 |
export { getComponentSlashItems } from "./slash-items";
|
|
|
|
| 1 |
export { COMPONENTS } from "./registry";
|
| 2 |
export type { ComponentDef, ComponentField } from "./registry";
|
| 3 |
export { createWrapperExtension, createAtomicExtension } from "./factory";
|
|
|
|
| 4 |
export { getComponentSlashItems } from "./slash-items";
|
frontend/src/editor/components/serializers.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// Auto-register MDX serializers for every component in the registry.
|
| 3 |
-
//
|
| 4 |
-
// Call `registerComponentSerializers()` once at startup (side-effect import).
|
| 5 |
-
// ---------------------------------------------------------------------------
|
| 6 |
-
|
| 7 |
-
import { registerSerializer, serializeNode } from "../../export/serializers/index";
|
| 8 |
-
import { COMPONENTS } from "./registry";
|
| 9 |
-
import type { ComponentDef } from "./registry";
|
| 10 |
-
|
| 11 |
-
function serializeAttrs(def: ComponentDef, attrs: Record<string, unknown>): string {
|
| 12 |
-
const parts: string[] = [];
|
| 13 |
-
|
| 14 |
-
for (const f of def.fields) {
|
| 15 |
-
const val = attrs[f.name];
|
| 16 |
-
if (val === undefined || val === null || val === f.default) continue;
|
| 17 |
-
|
| 18 |
-
if (f.type === "boolean") {
|
| 19 |
-
if (val) parts.push(f.name);
|
| 20 |
-
} else {
|
| 21 |
-
parts.push(`${f.name}="${String(val)}"`);
|
| 22 |
-
}
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
return parts.length ? ` ${parts.join(" ")}` : "";
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
export function registerComponentSerializers() {
|
| 29 |
-
for (const def of COMPONENTS) {
|
| 30 |
-
registerSerializer(def.name, (node) => {
|
| 31 |
-
const attrStr = serializeAttrs(def, node.attrs ?? {});
|
| 32 |
-
|
| 33 |
-
if (def.kind === "atomic") {
|
| 34 |
-
return `<${def.tag}${attrStr} />`;
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
const inner = node.content?.map(serializeNode).join("\n\n") ?? "";
|
| 38 |
-
return `<${def.tag}${attrStr}>\n${inner}\n</${def.tag}>`;
|
| 39 |
-
});
|
| 40 |
-
}
|
| 41 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/editor/frontmatter/FrontmatterHero.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
|
| 2 |
import {
|
| 3 |
Box,
|
| 4 |
-
Typography,
|
| 5 |
Chip,
|
| 6 |
IconButton,
|
| 7 |
-
Tooltip,
|
| 8 |
TextField,
|
|
|
|
|
|
|
| 9 |
} from "@mui/material";
|
| 10 |
import AddIcon from "@mui/icons-material/Add";
|
| 11 |
import CloseIcon from "@mui/icons-material/Close";
|
|
@@ -22,143 +22,233 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
|
|
| 22 |
const [showAuthorForm, setShowAuthorForm] = useState(false);
|
| 23 |
|
| 24 |
if (!data) {
|
| 25 |
-
return <Box sx={{
|
| 26 |
}
|
| 27 |
|
| 28 |
-
const
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
return (
|
| 31 |
-
<Box sx={{
|
| 32 |
-
{/*
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
onChange={(v) => update("subtitle", v)}
|
| 53 |
-
typographySx={{
|
| 54 |
-
fontSize: "1.15rem",
|
| 55 |
-
fontWeight: 400,
|
| 56 |
-
lineHeight: 1.5,
|
| 57 |
-
color: "text.secondary",
|
| 58 |
-
mb: 2.5,
|
| 59 |
-
}}
|
| 60 |
-
/>
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
size="small"
|
| 77 |
-
onClick={() => setShowAuthorForm(true)}
|
| 78 |
-
sx={{ color: "text.disabled", border: "1px dashed", borderColor: "divider", borderRadius: 1, p: 0.5 }}
|
| 79 |
-
>
|
| 80 |
-
<AddIcon sx={{ fontSize: 14 }} />
|
| 81 |
-
</IconButton>
|
| 82 |
-
</Tooltip>
|
| 83 |
</Box>
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
) : (
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
)}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
)}
|
| 103 |
-
</Box>
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
| 116 |
)}
|
| 117 |
-
<EditableText
|
| 118 |
-
value={data.doi}
|
| 119 |
-
placeholder="DOI"
|
| 120 |
-
onChange={(v) => update("doi", v)}
|
| 121 |
-
typographySx={{ fontSize: "0.8rem", color: "text.disabled", fontFamily: "monospace" }}
|
| 122 |
-
inline
|
| 123 |
-
/>
|
| 124 |
</Box>
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
{hasContent && (
|
| 128 |
-
<Box sx={{ borderBottom: "1px solid", borderColor: "divider", mt: 3, mb: 1 }} />
|
| 129 |
-
)}
|
| 130 |
-
|
| 131 |
-
{/* Author forms */}
|
| 132 |
-
{showAuthorForm && (
|
| 133 |
-
<AuthorInlineForm
|
| 134 |
-
affiliations={data.affiliations}
|
| 135 |
-
onSubmit={(author, newAff) => {
|
| 136 |
-
if (newAff) {
|
| 137 |
-
const idx = store!.addAffiliation(newAff);
|
| 138 |
-
author.affiliations = [...author.affiliations, idx];
|
| 139 |
-
}
|
| 140 |
-
store?.addAuthor(author);
|
| 141 |
-
setShowAuthorForm(false);
|
| 142 |
-
}}
|
| 143 |
-
onCancel={() => setShowAuthorForm(false)}
|
| 144 |
-
/>
|
| 145 |
-
)}
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
| 162 |
</Box>
|
| 163 |
);
|
| 164 |
}
|
|
@@ -172,9 +262,10 @@ interface EditableTextProps {
|
|
| 172 |
typographySx?: Record<string, any>;
|
| 173 |
multiline?: boolean;
|
| 174 |
inline?: boolean;
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
-
function EditableText({ value, placeholder, onChange, typographySx = {}, multiline, inline }: EditableTextProps) {
|
| 178 |
const [editing, setEditing] = useState(false);
|
| 179 |
const [draft, setDraft] = useState(value);
|
| 180 |
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
|
@@ -211,6 +302,7 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
|
|
| 211 |
|
| 212 |
const displayValue = value || placeholder;
|
| 213 |
const isEmpty = !value;
|
|
|
|
| 214 |
|
| 215 |
if (editing) {
|
| 216 |
return (
|
|
@@ -230,6 +322,7 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
|
|
| 230 |
disableUnderline: true,
|
| 231 |
sx: {
|
| 232 |
...typographySx,
|
|
|
|
| 233 |
p: 0,
|
| 234 |
m: 0,
|
| 235 |
},
|
|
@@ -245,6 +338,7 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
|
|
| 245 |
onClick={() => setEditing(true)}
|
| 246 |
sx={{
|
| 247 |
...typographySx,
|
|
|
|
| 248 |
cursor: "text",
|
| 249 |
opacity: isEmpty ? 0.35 : 1,
|
| 250 |
borderRadius: 1,
|
|
@@ -262,45 +356,6 @@ function EditableText({ value, placeholder, onChange, typographySx = {}, multili
|
|
| 262 |
);
|
| 263 |
}
|
| 264 |
|
| 265 |
-
// ---- Author chip ----
|
| 266 |
-
|
| 267 |
-
function AuthorChip({
|
| 268 |
-
author,
|
| 269 |
-
affiliations,
|
| 270 |
-
onEdit,
|
| 271 |
-
onRemove,
|
| 272 |
-
}: {
|
| 273 |
-
author: Author;
|
| 274 |
-
affiliations: Affiliation[];
|
| 275 |
-
onEdit: () => void;
|
| 276 |
-
onRemove: () => void;
|
| 277 |
-
}) {
|
| 278 |
-
const affIndices = author.affiliations || [];
|
| 279 |
-
|
| 280 |
-
return (
|
| 281 |
-
<Chip
|
| 282 |
-
label={
|
| 283 |
-
affIndices.length > 0
|
| 284 |
-
? `${author.name} (${affIndices.join(",")})`
|
| 285 |
-
: author.name
|
| 286 |
-
}
|
| 287 |
-
size="small"
|
| 288 |
-
onClick={onEdit}
|
| 289 |
-
onDelete={onRemove}
|
| 290 |
-
deleteIcon={<CloseIcon sx={{ fontSize: 12 }} />}
|
| 291 |
-
sx={{
|
| 292 |
-
fontSize: "0.8rem",
|
| 293 |
-
height: 26,
|
| 294 |
-
bgcolor: "rgba(255,255,255,0.06)",
|
| 295 |
-
border: "1px solid",
|
| 296 |
-
borderColor: "divider",
|
| 297 |
-
cursor: "pointer",
|
| 298 |
-
"&:hover": { borderColor: "text.disabled" },
|
| 299 |
-
}}
|
| 300 |
-
/>
|
| 301 |
-
);
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
// ---- Author inline form ----
|
| 305 |
|
| 306 |
function AuthorInlineForm({
|
|
|
|
| 1 |
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
|
| 2 |
import {
|
| 3 |
Box,
|
|
|
|
| 4 |
Chip,
|
| 5 |
IconButton,
|
|
|
|
| 6 |
TextField,
|
| 7 |
+
Tooltip,
|
| 8 |
+
Typography,
|
| 9 |
} from "@mui/material";
|
| 10 |
import AddIcon from "@mui/icons-material/Add";
|
| 11 |
import CloseIcon from "@mui/icons-material/Close";
|
|
|
|
| 22 |
const [showAuthorForm, setShowAuthorForm] = useState(false);
|
| 23 |
|
| 24 |
if (!data) {
|
| 25 |
+
return <Box sx={{ width: "100%", mb: 4 }} />;
|
| 26 |
}
|
| 27 |
|
| 28 |
+
const hasAuthors = data.authors.length > 0;
|
| 29 |
+
const hasAffiliations = data.affiliations.length > 0;
|
| 30 |
+
const multipleAffiliations = data.affiliations.length > 1;
|
| 31 |
+
const hasMeta = hasAuthors || data.published || data.doi;
|
| 32 |
|
| 33 |
return (
|
| 34 |
+
<Box sx={{ width: "100%", mb: 2 }}>
|
| 35 |
+
{/* Hero section - centered title + subtitle */}
|
| 36 |
+
<Box sx={{ textAlign: "center", pt: 4, pb: 2, px: 2 }}>
|
| 37 |
+
<Box sx={{ maxWidth: 780, mx: "auto" }}>
|
| 38 |
+
<EditableText
|
| 39 |
+
value={data.title}
|
| 40 |
+
placeholder="Article title"
|
| 41 |
+
onChange={(v) => update("title", v)}
|
| 42 |
+
typographySx={{
|
| 43 |
+
fontSize: "clamp(34px, 5vw, 54px)",
|
| 44 |
+
fontWeight: 800,
|
| 45 |
+
lineHeight: 1.12,
|
| 46 |
+
letterSpacing: "-0.02em",
|
| 47 |
+
color: "text.primary",
|
| 48 |
+
mb: 1,
|
| 49 |
+
textWrap: "balance",
|
| 50 |
+
}}
|
| 51 |
+
multiline
|
| 52 |
+
center
|
| 53 |
+
/>
|
| 54 |
+
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
<Box sx={{ maxWidth: "55%", mx: "auto" }}>
|
| 57 |
+
<EditableText
|
| 58 |
+
value={data.subtitle}
|
| 59 |
+
placeholder="Subtitle (optional)"
|
| 60 |
+
onChange={(v) => update("subtitle", v)}
|
| 61 |
+
typographySx={{
|
| 62 |
+
fontSize: "1.15em",
|
| 63 |
+
fontWeight: 400,
|
| 64 |
+
lineHeight: 1.6,
|
| 65 |
+
color: "text.secondary",
|
| 66 |
+
fontStyle: "italic",
|
| 67 |
+
}}
|
| 68 |
+
center
|
| 69 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
</Box>
|
| 71 |
+
</Box>
|
| 72 |
|
| 73 |
+
{/* Meta bar - matches published template */}
|
| 74 |
+
{hasMeta && (
|
| 75 |
+
<Box
|
| 76 |
+
sx={{
|
| 77 |
+
borderTop: "1px solid",
|
| 78 |
+
borderBottom: "1px solid",
|
| 79 |
+
borderColor: "divider",
|
| 80 |
+
py: 2,
|
| 81 |
+
fontSize: "0.9rem",
|
| 82 |
+
}}
|
| 83 |
+
>
|
| 84 |
+
<Box
|
| 85 |
+
sx={{
|
| 86 |
+
maxWidth: 980,
|
| 87 |
+
mx: "auto",
|
| 88 |
+
px: 2,
|
| 89 |
+
display: "flex",
|
| 90 |
+
flexDirection: "row",
|
| 91 |
+
justifyContent: "space-between",
|
| 92 |
+
gap: 1,
|
| 93 |
+
flexWrap: "wrap",
|
| 94 |
+
rowGap: 1.5,
|
| 95 |
+
}}
|
| 96 |
+
>
|
| 97 |
+
{/* Authors cell */}
|
| 98 |
+
<MetaCell label="Authors">
|
| 99 |
+
<Box sx={{ display: "flex", flexWrap: "wrap", m: 0, p: 0, listStyle: "none" }} component="ul">
|
| 100 |
+
{data.authors.map((author, i) => (
|
| 101 |
+
<Box component="li" key={`author-${i}`} sx={{ whiteSpace: "nowrap", p: 0 }}>
|
| 102 |
+
<Box
|
| 103 |
+
component="span"
|
| 104 |
+
onClick={() => setEditingAuthorIdx(i)}
|
| 105 |
+
sx={{
|
| 106 |
+
cursor: "pointer",
|
| 107 |
+
borderRadius: 0.5,
|
| 108 |
+
transition: "background 0.15s",
|
| 109 |
+
"&:hover": { bgcolor: "rgba(255,255,255,0.06)" },
|
| 110 |
+
}}
|
| 111 |
+
>
|
| 112 |
+
{author.url ? (
|
| 113 |
+
<Box component="span" sx={{ color: "primary.main", textDecoration: "underline", textUnderlineOffset: "2px", textDecorationThickness: "0.06em" }}>
|
| 114 |
+
{author.name}
|
| 115 |
+
</Box>
|
| 116 |
+
) : (
|
| 117 |
+
author.name
|
| 118 |
+
)}
|
| 119 |
+
{multipleAffiliations && author.affiliations?.length > 0 && (
|
| 120 |
+
<sup>{author.affiliations.join(",")}</sup>
|
| 121 |
+
)}
|
| 122 |
+
</Box>
|
| 123 |
+
{i < data.authors.length - 1 && <>, </>}
|
| 124 |
+
</Box>
|
| 125 |
+
))}
|
| 126 |
+
<Box component="li" sx={{ p: 0, display: "inline-flex", alignItems: "center", ml: 0.5 }}>
|
| 127 |
+
<Tooltip title="Add author" arrow>
|
| 128 |
+
<IconButton
|
| 129 |
+
size="small"
|
| 130 |
+
onClick={() => setShowAuthorForm(true)}
|
| 131 |
+
sx={{ color: "text.disabled", p: 0.25 }}
|
| 132 |
+
>
|
| 133 |
+
<AddIcon sx={{ fontSize: 14 }} />
|
| 134 |
+
</IconButton>
|
| 135 |
+
</Tooltip>
|
| 136 |
+
</Box>
|
| 137 |
+
</Box>
|
| 138 |
+
</MetaCell>
|
| 139 |
+
|
| 140 |
+
{/* Affiliations cell */}
|
| 141 |
+
{hasAffiliations && (
|
| 142 |
+
<MetaCell label="Affiliations">
|
| 143 |
+
{multipleAffiliations ? (
|
| 144 |
+
<Box component="ol" sx={{ m: 0, pl: "1.25em" }}>
|
| 145 |
+
{data.affiliations.map((aff, i) => (
|
| 146 |
+
<Box component="li" key={`aff-${i}`} sx={{ m: 0 }}>
|
| 147 |
+
{aff.url ? (
|
| 148 |
+
<a href={aff.url} target="_blank" rel="noopener noreferrer" style={{ color: "inherit" }}>
|
| 149 |
+
{aff.name}
|
| 150 |
+
</a>
|
| 151 |
+
) : (
|
| 152 |
+
aff.name
|
| 153 |
+
)}
|
| 154 |
+
</Box>
|
| 155 |
+
))}
|
| 156 |
+
</Box>
|
| 157 |
) : (
|
| 158 |
+
<Box>
|
| 159 |
+
{data.affiliations[0].url ? (
|
| 160 |
+
<a href={data.affiliations[0].url} target="_blank" rel="noopener noreferrer" style={{ color: "inherit" }}>
|
| 161 |
+
{data.affiliations[0].name}
|
| 162 |
+
</a>
|
| 163 |
+
) : (
|
| 164 |
+
data.affiliations[0].name
|
| 165 |
+
)}
|
| 166 |
+
</Box>
|
| 167 |
)}
|
| 168 |
+
</MetaCell>
|
| 169 |
+
)}
|
| 170 |
+
|
| 171 |
+
{/* Published cell */}
|
| 172 |
+
<MetaCell label="Published">
|
| 173 |
+
<EditableText
|
| 174 |
+
value={data.published}
|
| 175 |
+
placeholder="Date"
|
| 176 |
+
onChange={(v) => update("published", v)}
|
| 177 |
+
typographySx={{ fontSize: "0.9rem", color: "text.primary" }}
|
| 178 |
+
inline
|
| 179 |
+
/>
|
| 180 |
+
</MetaCell>
|
| 181 |
+
|
| 182 |
+
{/* DOI cell */}
|
| 183 |
+
<MetaCell label="DOI">
|
| 184 |
+
<EditableText
|
| 185 |
+
value={data.doi}
|
| 186 |
+
placeholder="10.xxxx/xxxxx"
|
| 187 |
+
onChange={(v) => update("doi", v)}
|
| 188 |
+
typographySx={{ fontSize: "0.9rem", color: "text.primary", fontFamily: "monospace" }}
|
| 189 |
+
inline
|
| 190 |
+
/>
|
| 191 |
+
</MetaCell>
|
| 192 |
</Box>
|
| 193 |
+
</Box>
|
| 194 |
+
)}
|
| 195 |
+
|
| 196 |
+
{/* Author forms */}
|
| 197 |
+
<Box sx={{ maxWidth: 680, mx: "auto", px: 2 }}>
|
| 198 |
+
{showAuthorForm && (
|
| 199 |
+
<AuthorInlineForm
|
| 200 |
+
affiliations={data.affiliations}
|
| 201 |
+
onSubmit={(author, newAff) => {
|
| 202 |
+
if (newAff) {
|
| 203 |
+
const idx = store!.addAffiliation(newAff);
|
| 204 |
+
author.affiliations = [...author.affiliations, idx];
|
| 205 |
+
}
|
| 206 |
+
store?.addAuthor(author);
|
| 207 |
+
setShowAuthorForm(false);
|
| 208 |
+
}}
|
| 209 |
+
onCancel={() => setShowAuthorForm(false)}
|
| 210 |
+
/>
|
| 211 |
)}
|
|
|
|
| 212 |
|
| 213 |
+
{editingAuthorIdx !== null && data.authors[editingAuthorIdx] && (
|
| 214 |
+
<AuthorInlineForm
|
| 215 |
+
initial={data.authors[editingAuthorIdx]}
|
| 216 |
+
affiliations={data.affiliations}
|
| 217 |
+
onSubmit={(author, newAff) => {
|
| 218 |
+
if (newAff) {
|
| 219 |
+
const idx = store!.addAffiliation(newAff);
|
| 220 |
+
author.affiliations = [...author.affiliations, idx];
|
| 221 |
+
}
|
| 222 |
+
store?.updateAuthor(editingAuthorIdx, author);
|
| 223 |
+
setEditingAuthorIdx(null);
|
| 224 |
+
}}
|
| 225 |
+
onCancel={() => setEditingAuthorIdx(null)}
|
| 226 |
+
/>
|
| 227 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
</Box>
|
| 229 |
+
</Box>
|
| 230 |
+
);
|
| 231 |
+
}
|
| 232 |
|
| 233 |
+
// ---- Meta cell (labeled column in the meta bar) ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
+
function MetaCell({ label, children }: { label: string; children: React.ReactNode }) {
|
| 236 |
+
return (
|
| 237 |
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, maxWidth: 400 }}>
|
| 238 |
+
<Typography
|
| 239 |
+
component="h3"
|
| 240 |
+
sx={{
|
| 241 |
+
m: 0,
|
| 242 |
+
fontSize: "12px",
|
| 243 |
+
fontWeight: 400,
|
| 244 |
+
color: "text.disabled",
|
| 245 |
+
textTransform: "uppercase",
|
| 246 |
+
letterSpacing: "0.02em",
|
| 247 |
+
}}
|
| 248 |
+
>
|
| 249 |
+
{label}
|
| 250 |
+
</Typography>
|
| 251 |
+
<Box sx={{ m: 0 }}>{children}</Box>
|
| 252 |
</Box>
|
| 253 |
);
|
| 254 |
}
|
|
|
|
| 262 |
typographySx?: Record<string, any>;
|
| 263 |
multiline?: boolean;
|
| 264 |
inline?: boolean;
|
| 265 |
+
center?: boolean;
|
| 266 |
}
|
| 267 |
|
| 268 |
+
function EditableText({ value, placeholder, onChange, typographySx = {}, multiline, inline, center }: EditableTextProps) {
|
| 269 |
const [editing, setEditing] = useState(false);
|
| 270 |
const [draft, setDraft] = useState(value);
|
| 271 |
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
|
|
|
| 302 |
|
| 303 |
const displayValue = value || placeholder;
|
| 304 |
const isEmpty = !value;
|
| 305 |
+
const textAlign = center ? "center" : undefined;
|
| 306 |
|
| 307 |
if (editing) {
|
| 308 |
return (
|
|
|
|
| 322 |
disableUnderline: true,
|
| 323 |
sx: {
|
| 324 |
...typographySx,
|
| 325 |
+
textAlign,
|
| 326 |
p: 0,
|
| 327 |
m: 0,
|
| 328 |
},
|
|
|
|
| 338 |
onClick={() => setEditing(true)}
|
| 339 |
sx={{
|
| 340 |
...typographySx,
|
| 341 |
+
textAlign,
|
| 342 |
cursor: "text",
|
| 343 |
opacity: isEmpty ? 0.35 : 1,
|
| 344 |
borderRadius: 1,
|
|
|
|
| 356 |
);
|
| 357 |
}
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
// ---- Author inline form ----
|
| 360 |
|
| 361 |
function AuthorInlineForm({
|
frontend/src/editor/frontmatter/SettingsDrawer.tsx
CHANGED
|
@@ -9,12 +9,8 @@ import {
|
|
| 9 |
Switch,
|
| 10 |
Divider,
|
| 11 |
IconButton,
|
| 12 |
-
Accordion,
|
| 13 |
-
AccordionSummary,
|
| 14 |
-
AccordionDetails,
|
| 15 |
} from "@mui/material";
|
| 16 |
import CloseIcon from "@mui/icons-material/Close";
|
| 17 |
-
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
| 18 |
import { useState, useEffect } from "react";
|
| 19 |
import { useFrontmatter } from "./useFrontmatter";
|
| 20 |
import type { FrontmatterStore, FrontmatterData } from "./frontmatter-store";
|
|
@@ -49,17 +45,19 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
|
|
| 49 |
anchor="right"
|
| 50 |
open={open}
|
| 51 |
onClose={onClose}
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
},
|
| 60 |
}}
|
| 61 |
>
|
| 62 |
-
<Box sx={{
|
| 63 |
{/* Header */}
|
| 64 |
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
|
| 65 |
<Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "text.secondary", fontSize: "0.75rem" }}>
|
|
@@ -133,6 +131,7 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
|
|
| 133 |
|
| 134 |
{/* Toggles */}
|
| 135 |
<FormControlLabel
|
|
|
|
| 136 |
control={
|
| 137 |
<Switch
|
| 138 |
size="small"
|
|
@@ -144,6 +143,7 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
|
|
| 144 |
/>
|
| 145 |
|
| 146 |
<FormControlLabel
|
|
|
|
| 147 |
control={
|
| 148 |
<Switch
|
| 149 |
size="small"
|
|
@@ -154,58 +154,42 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
|
|
| 154 |
label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>Auto-collapse TOC</Typography>}
|
| 155 |
/>
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
<
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
<
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
size="small"
|
| 190 |
-
fullWidth
|
| 191 |
-
placeholder="https://..."
|
| 192 |
-
value={data.seoThumbImage}
|
| 193 |
-
onChange={(e) => update("seoThumbImage", e.target.value)}
|
| 194 |
-
/>
|
| 195 |
-
</FieldGroup>
|
| 196 |
-
|
| 197 |
-
<FormControlLabel
|
| 198 |
-
control={
|
| 199 |
-
<Switch
|
| 200 |
-
size="small"
|
| 201 |
-
checked={data.pdfProOnly}
|
| 202 |
-
onChange={(_, v) => update("pdfProOnly", v)}
|
| 203 |
-
/>
|
| 204 |
-
}
|
| 205 |
-
label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>PDF Pro only</Typography>}
|
| 206 |
/>
|
| 207 |
-
|
| 208 |
-
|
|
|
|
| 209 |
</Box>
|
| 210 |
</Box>
|
| 211 |
</Drawer>
|
|
|
|
| 9 |
Switch,
|
| 10 |
Divider,
|
| 11 |
IconButton,
|
|
|
|
|
|
|
|
|
|
| 12 |
} from "@mui/material";
|
| 13 |
import CloseIcon from "@mui/icons-material/Close";
|
|
|
|
| 14 |
import { useState, useEffect } from "react";
|
| 15 |
import { useFrontmatter } from "./useFrontmatter";
|
| 16 |
import type { FrontmatterStore, FrontmatterData } from "./frontmatter-store";
|
|
|
|
| 45 |
anchor="right"
|
| 46 |
open={open}
|
| 47 |
onClose={onClose}
|
| 48 |
+
slotProps={{
|
| 49 |
+
paper: {
|
| 50 |
+
sx: {
|
| 51 |
+
width: 380,
|
| 52 |
+
bgcolor: "background.paper",
|
| 53 |
+
backgroundImage: "none",
|
| 54 |
+
borderLeft: "1px solid",
|
| 55 |
+
borderColor: "divider",
|
| 56 |
+
},
|
| 57 |
},
|
| 58 |
}}
|
| 59 |
>
|
| 60 |
+
<Box sx={{ px: 2.5, py: 2, display: "flex", flexDirection: "column", height: "100%" }}>
|
| 61 |
{/* Header */}
|
| 62 |
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
|
| 63 |
<Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "text.secondary", fontSize: "0.75rem" }}>
|
|
|
|
| 131 |
|
| 132 |
{/* Toggles */}
|
| 133 |
<FormControlLabel
|
| 134 |
+
sx={{ ml: 0 }}
|
| 135 |
control={
|
| 136 |
<Switch
|
| 137 |
size="small"
|
|
|
|
| 143 |
/>
|
| 144 |
|
| 145 |
<FormControlLabel
|
| 146 |
+
sx={{ ml: 0 }}
|
| 147 |
control={
|
| 148 |
<Switch
|
| 149 |
size="small"
|
|
|
|
| 154 |
label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>Auto-collapse TOC</Typography>}
|
| 155 |
/>
|
| 156 |
|
| 157 |
+
<Divider />
|
| 158 |
+
|
| 159 |
+
{/* Licence / SEO / PDF Pro */}
|
| 160 |
+
<FieldGroup label="Licence">
|
| 161 |
+
<TextField
|
| 162 |
+
size="small"
|
| 163 |
+
fullWidth
|
| 164 |
+
multiline
|
| 165 |
+
minRows={2}
|
| 166 |
+
placeholder="e.g. CC BY 4.0 (HTML allowed)"
|
| 167 |
+
value={data.licence}
|
| 168 |
+
onChange={(e) => update("licence", e.target.value)}
|
| 169 |
+
/>
|
| 170 |
+
</FieldGroup>
|
| 171 |
+
|
| 172 |
+
<FieldGroup label="SEO thumbnail image">
|
| 173 |
+
<TextField
|
| 174 |
+
size="small"
|
| 175 |
+
fullWidth
|
| 176 |
+
placeholder="https://..."
|
| 177 |
+
value={data.seoThumbImage}
|
| 178 |
+
onChange={(e) => update("seoThumbImage", e.target.value)}
|
| 179 |
+
/>
|
| 180 |
+
</FieldGroup>
|
| 181 |
+
|
| 182 |
+
<FormControlLabel
|
| 183 |
+
sx={{ ml: 0 }}
|
| 184 |
+
control={
|
| 185 |
+
<Switch
|
| 186 |
+
size="small"
|
| 187 |
+
checked={data.pdfProOnly}
|
| 188 |
+
onChange={(_, v) => update("pdfProOnly", v)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
/>
|
| 190 |
+
}
|
| 191 |
+
label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>PDF Pro only</Typography>}
|
| 192 |
+
/>
|
| 193 |
</Box>
|
| 194 |
</Box>
|
| 195 |
</Drawer>
|
frontend/src/editor/frontmatter/seed-frontmatter.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function seedFrontmatter(store: FrontmatterStore) {
|
|
| 12 |
subtitle: "How model size, data, and compute interact",
|
| 13 |
description: "An empirical study of scaling behavior across model size, data, and compute budgets.",
|
| 14 |
published: "Apr. 13, 2026",
|
| 15 |
-
doi: "10.1234/
|
| 16 |
template: "article",
|
| 17 |
});
|
| 18 |
|
|
|
|
| 12 |
subtitle: "How model size, data, and compute interact",
|
| 13 |
description: "An empirical study of scaling behavior across model size, data, and compute budgets.",
|
| 14 |
published: "Apr. 13, 2026",
|
| 15 |
+
doi: "10.1234/science-article-template-editor.2026",
|
| 16 |
template: "article",
|
| 17 |
});
|
| 18 |
|
frontend/src/export/serializers/index.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
| 1 |
-
import type { JSONContent } from "@tiptap/core";
|
| 2 |
-
|
| 3 |
-
type Serializer = (node: JSONContent) => string;
|
| 4 |
-
|
| 5 |
-
const serializers: Record<string, Serializer> = {};
|
| 6 |
-
|
| 7 |
-
export function registerSerializer(type: string, fn: Serializer) {
|
| 8 |
-
serializers[type] = fn;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
export function serializeNode(node: JSONContent): string {
|
| 12 |
-
const fn = serializers[node.type!];
|
| 13 |
-
if (fn) return fn(node);
|
| 14 |
-
if (node.text) return serializeMarks(node);
|
| 15 |
-
if (node.content) return node.content.map(serializeNode).join("");
|
| 16 |
-
return "";
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
function serializeMarks(node: JSONContent): string {
|
| 20 |
-
let text = node.text || "";
|
| 21 |
-
if (!node.marks) return text;
|
| 22 |
-
|
| 23 |
-
for (const mark of node.marks) {
|
| 24 |
-
switch (mark.type) {
|
| 25 |
-
case "bold":
|
| 26 |
-
text = `**${text}**`;
|
| 27 |
-
break;
|
| 28 |
-
case "italic":
|
| 29 |
-
text = `*${text}*`;
|
| 30 |
-
break;
|
| 31 |
-
case "strike":
|
| 32 |
-
text = `~~${text}~~`;
|
| 33 |
-
break;
|
| 34 |
-
case "code":
|
| 35 |
-
text = `\`${text}\``;
|
| 36 |
-
break;
|
| 37 |
-
case "link":
|
| 38 |
-
text = `[${text}](${mark.attrs?.href || ""})`;
|
| 39 |
-
break;
|
| 40 |
-
}
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
return text;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
function inlineContent(node: JSONContent): string {
|
| 47 |
-
if (!node.content) return "";
|
| 48 |
-
return node.content.map(serializeNode).join("");
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
// --- Built-in serializers ---
|
| 52 |
-
|
| 53 |
-
registerSerializer("paragraph", (node) => inlineContent(node));
|
| 54 |
-
|
| 55 |
-
registerSerializer("heading", (node) => {
|
| 56 |
-
const level = node.attrs?.level || 1;
|
| 57 |
-
const prefix = "#".repeat(level);
|
| 58 |
-
return `${prefix} ${inlineContent(node)}`;
|
| 59 |
-
});
|
| 60 |
-
|
| 61 |
-
registerSerializer("bulletList", (node) => {
|
| 62 |
-
if (!node.content) return "";
|
| 63 |
-
return node.content
|
| 64 |
-
.map((item) => {
|
| 65 |
-
const inner = item.content?.map(serializeNode).join("\n") || "";
|
| 66 |
-
return `- ${inner}`;
|
| 67 |
-
})
|
| 68 |
-
.join("\n");
|
| 69 |
-
});
|
| 70 |
-
|
| 71 |
-
registerSerializer("orderedList", (node) => {
|
| 72 |
-
if (!node.content) return "";
|
| 73 |
-
return node.content
|
| 74 |
-
.map((item, i) => {
|
| 75 |
-
const inner = item.content?.map(serializeNode).join("\n") || "";
|
| 76 |
-
return `${i + 1}. ${inner}`;
|
| 77 |
-
})
|
| 78 |
-
.join("\n");
|
| 79 |
-
});
|
| 80 |
-
|
| 81 |
-
registerSerializer("blockquote", (node) => {
|
| 82 |
-
if (!node.content) return "";
|
| 83 |
-
const inner = node.content.map(serializeNode).join("\n");
|
| 84 |
-
return inner
|
| 85 |
-
.split("\n")
|
| 86 |
-
.map((line) => `> ${line}`)
|
| 87 |
-
.join("\n");
|
| 88 |
-
});
|
| 89 |
-
|
| 90 |
-
registerSerializer("codeBlock", (node) => {
|
| 91 |
-
const lang = node.attrs?.language || "";
|
| 92 |
-
const code = inlineContent(node);
|
| 93 |
-
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
-
registerSerializer("horizontalRule", () => "---");
|
| 97 |
-
|
| 98 |
-
registerSerializer("hardBreak", () => " \n");
|
| 99 |
-
|
| 100 |
-
registerSerializer("listItem", (node) => {
|
| 101 |
-
if (!node.content) return "";
|
| 102 |
-
return node.content.map(serializeNode).join("\n");
|
| 103 |
-
});
|
| 104 |
-
|
| 105 |
-
// --- Inline atoms ---
|
| 106 |
-
|
| 107 |
-
registerSerializer("glossary", (node) => {
|
| 108 |
-
const term = node.attrs?.term || "";
|
| 109 |
-
const definition = node.attrs?.definition || "";
|
| 110 |
-
return `<Glossary term="${term}" definition="${definition}">${term}</Glossary>`;
|
| 111 |
-
});
|
| 112 |
-
|
| 113 |
-
registerSerializer("footnote", (node) => {
|
| 114 |
-
return `[^fn]: ${node.attrs?.content || ""}`;
|
| 115 |
-
});
|
| 116 |
-
|
| 117 |
-
// --- Stack (multi-column) ---
|
| 118 |
-
|
| 119 |
-
registerSerializer("stack", (node) => {
|
| 120 |
-
const layout = node.attrs?.layout || "2-column";
|
| 121 |
-
const gap = node.attrs?.gap || "medium";
|
| 122 |
-
const attrs: string[] = [];
|
| 123 |
-
if (layout !== "2-column") attrs.push(`layout="${layout}"`);
|
| 124 |
-
if (gap !== "medium") attrs.push(`gap="${gap}"`);
|
| 125 |
-
const attrStr = attrs.length ? ` ${attrs.join(" ")}` : "";
|
| 126 |
-
const inner = node.content?.map(serializeNode).join("\n\n") ?? "";
|
| 127 |
-
return `<Stack${attrStr}>\n${inner}\n</Stack>`;
|
| 128 |
-
});
|
| 129 |
-
|
| 130 |
-
registerSerializer("stackColumn", (node) => {
|
| 131 |
-
if (!node.content) return "";
|
| 132 |
-
return node.content.map(serializeNode).join("\n\n");
|
| 133 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/export/to-mdx.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
| 1 |
-
import type { JSONContent } from "@tiptap/core";
|
| 2 |
-
import { serializeNode } from "./serializers/index";
|
| 3 |
-
import type { FrontmatterData } from "../editor/frontmatter/frontmatter-store";
|
| 4 |
-
|
| 5 |
-
export function toMdx(doc: JSONContent, frontmatter?: FrontmatterData): string {
|
| 6 |
-
const body = doc.content
|
| 7 |
-
? doc.content.map((node) => serializeNode(node)).join("\n\n")
|
| 8 |
-
: "";
|
| 9 |
-
|
| 10 |
-
if (!frontmatter) return body;
|
| 11 |
-
|
| 12 |
-
const yaml = serializeFrontmatter(frontmatter);
|
| 13 |
-
if (!yaml) return body;
|
| 14 |
-
|
| 15 |
-
return `---\n${yaml}---\n\n${body}`;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
function serializeFrontmatter(fm: FrontmatterData): string {
|
| 19 |
-
const lines: string[] = [];
|
| 20 |
-
|
| 21 |
-
if (fm.title) lines.push(`title: ${yamlString(fm.title)}`);
|
| 22 |
-
if (fm.subtitle) lines.push(`subtitle: ${yamlString(fm.subtitle)}`);
|
| 23 |
-
if (fm.description) lines.push(`description: ${yamlString(fm.description)}`);
|
| 24 |
-
|
| 25 |
-
if (fm.authors.length > 0) {
|
| 26 |
-
lines.push("authors:");
|
| 27 |
-
for (const author of fm.authors) {
|
| 28 |
-
lines.push(` - name: ${yamlString(author.name)}`);
|
| 29 |
-
if (author.url) lines.push(` url: ${yamlString(author.url)}`);
|
| 30 |
-
if (author.affiliations.length > 0) {
|
| 31 |
-
lines.push(` affiliations: [${author.affiliations.join(", ")}]`);
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
if (fm.affiliations.length > 0) {
|
| 37 |
-
lines.push("affiliations:");
|
| 38 |
-
for (const aff of fm.affiliations) {
|
| 39 |
-
lines.push(` - name: ${yamlString(aff.name)}`);
|
| 40 |
-
if (aff.url) lines.push(` url: ${yamlString(aff.url)}`);
|
| 41 |
-
}
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
if (fm.published) lines.push(`published: ${yamlString(fm.published)}`);
|
| 45 |
-
if (fm.template !== "article") lines.push(`template: ${yamlString(fm.template)}`);
|
| 46 |
-
if (fm.banner && fm.banner !== "banner.html") lines.push(`banner: ${yamlString(fm.banner)}`);
|
| 47 |
-
if (fm.doi) lines.push(`doi: ${yamlString(fm.doi)}`);
|
| 48 |
-
if (!fm.showPdf) lines.push("showPdf: false");
|
| 49 |
-
if (fm.tableOfContentsAutoCollapse) lines.push("tableOfContentsAutoCollapse: true");
|
| 50 |
-
if (fm.licence) lines.push(`licence: ${yamlString(fm.licence)}`);
|
| 51 |
-
if (fm.pdfProOnly) lines.push("pdfProOnly: true");
|
| 52 |
-
if (fm.seoThumbImage) lines.push(`seoThumbImage: ${yamlString(fm.seoThumbImage)}`);
|
| 53 |
-
|
| 54 |
-
if (fm.links.length > 0) {
|
| 55 |
-
lines.push("links:");
|
| 56 |
-
for (const link of fm.links) {
|
| 57 |
-
lines.push(` - label: ${yamlString(link.label)}`);
|
| 58 |
-
lines.push(` url: ${yamlString(link.url)}`);
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
return lines.length > 0 ? lines.join("\n") + "\n" : "";
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
function yamlString(value: string): string {
|
| 66 |
-
if (/[\n:"{}[\],&*?|>!%@`#]/.test(value) || value !== value.trim()) {
|
| 67 |
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
| 68 |
-
}
|
| 69 |
-
return `"${value}"`;
|
| 70 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/hooks/useAgentChat.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { useCallback, useRef, useState } from "react";
|
|
| 7 |
import type { Editor } from "@tiptap/core";
|
| 8 |
import type { UndoManager } from "yjs";
|
| 9 |
import type { UIMessage } from "ai";
|
| 10 |
-
import { toMdx } from "../export/to-mdx";
|
| 11 |
import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
|
| 12 |
|
| 13 |
interface UseAgentChatOptions {
|
|
@@ -29,7 +28,7 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
|
|
| 29 |
const { from, to } = editor.state.selection;
|
| 30 |
const hasSelection = from !== to;
|
| 31 |
|
| 32 |
-
const document =
|
| 33 |
const selection = hasSelection
|
| 34 |
? editor.state.doc.textBetween(from, to, "\n")
|
| 35 |
: undefined;
|
|
|
|
| 7 |
import type { Editor } from "@tiptap/core";
|
| 8 |
import type { UndoManager } from "yjs";
|
| 9 |
import type { UIMessage } from "ai";
|
|
|
|
| 10 |
import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
|
| 11 |
|
| 12 |
interface UseAgentChatOptions {
|
|
|
|
| 28 |
const { from, to } = editor.state.selection;
|
| 29 |
const hasSelection = from !== to;
|
| 30 |
|
| 31 |
+
const document = editor.getText("\n");
|
| 32 |
const selection = hasSelection
|
| 33 |
? editor.state.doc.textBetween(from, to, "\n")
|
| 34 |
: undefined;
|