feat(editor): drag-and-drop reordering for authors and affiliations
Browse filesAuthors and affiliations in the hero meta header can now be reordered by
dragging. Reordering affiliations remaps every author's 1-based affiliation
indices in the same Yjs transaction, so superscripts keep pointing at the
right institution. Drop affordances (grab cursor + primary-color edge
marker) live in the editor-only _hero-editable.css and never leak into the
published HTML.
Co-authored-by: Cursor <cursoragent@cursor.com>
frontend/src/editor/frontmatter/FrontmatterHero.tsx
CHANGED
|
@@ -30,6 +30,15 @@ export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
|
|
| 30 |
const [editingAuthorIdx, setEditingAuthorIdx] = useState<number | null>(null);
|
| 31 |
const [showAuthorForm, setShowAuthorForm] = useState(false);
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const bannerSrc = data?.banner || "";
|
| 34 |
const [bannerHtml, setBannerHtml] = useState("");
|
| 35 |
const [uploadingBanner, setUploadingBanner] = useState(false);
|
|
@@ -203,26 +212,73 @@ export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
|
|
| 203 |
<h3>Authors</h3>
|
| 204 |
{hasAuthors ? (
|
| 205 |
<ul className="authors">
|
| 206 |
-
{data.authors.map((author, i) =>
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
>
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
<li className="author-add-btn">
|
| 227 |
<Tooltip title="Add author">
|
| 228 |
<button
|
|
@@ -252,17 +308,63 @@ export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
|
|
| 252 |
<h3>Affiliations</h3>
|
| 253 |
{multipleAffiliations ? (
|
| 254 |
<ol className="affiliations">
|
| 255 |
-
{data.affiliations.map((aff, i) =>
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
)
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
</ol>
|
| 267 |
) : (
|
| 268 |
<p>
|
|
|
|
| 30 |
const [editingAuthorIdx, setEditingAuthorIdx] = useState<number | null>(null);
|
| 31 |
const [showAuthorForm, setShowAuthorForm] = useState(false);
|
| 32 |
|
| 33 |
+
// Drag-and-drop reordering state for the meta header. `drag` is the index
|
| 34 |
+
// being dragged, `over` the index currently hovered as a drop target. Both
|
| 35 |
+
// reset on drop / dragend. Authors and affiliations track their own pair so
|
| 36 |
+
// a drag in one list never highlights the other.
|
| 37 |
+
const [dragAuthor, setDragAuthor] = useState<number | null>(null);
|
| 38 |
+
const [overAuthor, setOverAuthor] = useState<number | null>(null);
|
| 39 |
+
const [dragAff, setDragAff] = useState<number | null>(null);
|
| 40 |
+
const [overAff, setOverAff] = useState<number | null>(null);
|
| 41 |
+
|
| 42 |
const bannerSrc = data?.banner || "";
|
| 43 |
const [bannerHtml, setBannerHtml] = useState("");
|
| 44 |
const [uploadingBanner, setUploadingBanner] = useState(false);
|
|
|
|
| 212 |
<h3>Authors</h3>
|
| 213 |
{hasAuthors ? (
|
| 214 |
<ul className="authors">
|
| 215 |
+
{data.authors.map((author, i) => {
|
| 216 |
+
const canReorder = data.authors.length > 1;
|
| 217 |
+
const cls = [
|
| 218 |
+
"author-item",
|
| 219 |
+
dragAuthor === i ? "author-item--dragging" : "",
|
| 220 |
+
overAuthor === i && dragAuthor !== null && dragAuthor !== i
|
| 221 |
+
? "author-item--dragover"
|
| 222 |
+
: "",
|
| 223 |
+
]
|
| 224 |
+
.filter(Boolean)
|
| 225 |
+
.join(" ");
|
| 226 |
+
return (
|
| 227 |
+
<li
|
| 228 |
+
key={`author-${i}`}
|
| 229 |
+
className={cls}
|
| 230 |
+
draggable={canReorder}
|
| 231 |
+
title={canReorder ? "Drag to reorder" : undefined}
|
| 232 |
+
onDragStart={(e) => {
|
| 233 |
+
setDragAuthor(i);
|
| 234 |
+
e.dataTransfer.effectAllowed = "move";
|
| 235 |
+
}}
|
| 236 |
+
onDragOver={(e) => {
|
| 237 |
+
if (dragAuthor === null) return;
|
| 238 |
+
e.preventDefault();
|
| 239 |
+
e.dataTransfer.dropEffect = "move";
|
| 240 |
+
if (overAuthor !== i) setOverAuthor(i);
|
| 241 |
+
}}
|
| 242 |
+
onDragLeave={() =>
|
| 243 |
+
setOverAuthor((v) => (v === i ? null : v))
|
| 244 |
+
}
|
| 245 |
+
onDrop={(e) => {
|
| 246 |
+
e.preventDefault();
|
| 247 |
+
if (dragAuthor !== null && dragAuthor !== i) {
|
| 248 |
+
store?.moveAuthor(dragAuthor, i);
|
| 249 |
+
}
|
| 250 |
+
setDragAuthor(null);
|
| 251 |
+
setOverAuthor(null);
|
| 252 |
+
}}
|
| 253 |
+
onDragEnd={() => {
|
| 254 |
+
setDragAuthor(null);
|
| 255 |
+
setOverAuthor(null);
|
| 256 |
+
}}
|
| 257 |
>
|
| 258 |
+
<span
|
| 259 |
+
className="author-editable"
|
| 260 |
+
onClick={() => setEditingAuthorIdx(i)}
|
| 261 |
+
>
|
| 262 |
+
{author.url ? (
|
| 263 |
+
<a
|
| 264 |
+
href={author.url}
|
| 265 |
+
target="_blank"
|
| 266 |
+
rel="noopener noreferrer"
|
| 267 |
+
draggable={false}
|
| 268 |
+
>
|
| 269 |
+
{author.name}
|
| 270 |
+
</a>
|
| 271 |
+
) : (
|
| 272 |
+
author.name
|
| 273 |
+
)}
|
| 274 |
+
{multipleAffiliations && author.affiliations?.length > 0 && (
|
| 275 |
+
<sup>{author.affiliations.join(",")}</sup>
|
| 276 |
+
)}
|
| 277 |
+
</span>
|
| 278 |
+
{i < data.authors.length - 1 && <>, </>}
|
| 279 |
+
</li>
|
| 280 |
+
);
|
| 281 |
+
})}
|
| 282 |
<li className="author-add-btn">
|
| 283 |
<Tooltip title="Add author">
|
| 284 |
<button
|
|
|
|
| 308 |
<h3>Affiliations</h3>
|
| 309 |
{multipleAffiliations ? (
|
| 310 |
<ol className="affiliations">
|
| 311 |
+
{data.affiliations.map((aff, i) => {
|
| 312 |
+
const cls = [
|
| 313 |
+
"aff-item",
|
| 314 |
+
dragAff === i ? "aff-item--dragging" : "",
|
| 315 |
+
overAff === i && dragAff !== null && dragAff !== i
|
| 316 |
+
? "aff-item--dragover"
|
| 317 |
+
: "",
|
| 318 |
+
]
|
| 319 |
+
.filter(Boolean)
|
| 320 |
+
.join(" ");
|
| 321 |
+
return (
|
| 322 |
+
<li
|
| 323 |
+
key={`aff-${i}`}
|
| 324 |
+
className={cls}
|
| 325 |
+
draggable
|
| 326 |
+
title="Drag to reorder"
|
| 327 |
+
onDragStart={(e) => {
|
| 328 |
+
setDragAff(i);
|
| 329 |
+
e.dataTransfer.effectAllowed = "move";
|
| 330 |
+
}}
|
| 331 |
+
onDragOver={(e) => {
|
| 332 |
+
if (dragAff === null) return;
|
| 333 |
+
e.preventDefault();
|
| 334 |
+
e.dataTransfer.dropEffect = "move";
|
| 335 |
+
if (overAff !== i) setOverAff(i);
|
| 336 |
+
}}
|
| 337 |
+
onDragLeave={() =>
|
| 338 |
+
setOverAff((v) => (v === i ? null : v))
|
| 339 |
+
}
|
| 340 |
+
onDrop={(e) => {
|
| 341 |
+
e.preventDefault();
|
| 342 |
+
if (dragAff !== null && dragAff !== i) {
|
| 343 |
+
store?.moveAffiliation(dragAff, i);
|
| 344 |
+
}
|
| 345 |
+
setDragAff(null);
|
| 346 |
+
setOverAff(null);
|
| 347 |
+
}}
|
| 348 |
+
onDragEnd={() => {
|
| 349 |
+
setDragAff(null);
|
| 350 |
+
setOverAff(null);
|
| 351 |
+
}}
|
| 352 |
+
>
|
| 353 |
+
{aff.url ? (
|
| 354 |
+
<a
|
| 355 |
+
href={aff.url}
|
| 356 |
+
target="_blank"
|
| 357 |
+
rel="noopener noreferrer"
|
| 358 |
+
draggable={false}
|
| 359 |
+
>
|
| 360 |
+
{aff.name}
|
| 361 |
+
</a>
|
| 362 |
+
) : (
|
| 363 |
+
aff.name
|
| 364 |
+
)}
|
| 365 |
+
</li>
|
| 366 |
+
);
|
| 367 |
+
})}
|
| 368 |
</ol>
|
| 369 |
) : (
|
| 370 |
<p>
|
frontend/src/editor/frontmatter/frontmatter-store.ts
CHANGED
|
@@ -169,6 +169,21 @@ export function createFrontmatterStore(ydoc: Y.Doc) {
|
|
| 169 |
}
|
| 170 |
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
// --- Affiliations (concurrent-safe) ---
|
| 173 |
|
| 174 |
function addAffiliation(affiliation: Affiliation): number {
|
|
@@ -176,6 +191,42 @@ export function createFrontmatterStore(ydoc: Y.Doc) {
|
|
| 176 |
return yAffiliations.length; // 1-based index
|
| 177 |
}
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
// --- Observe all changes ---
|
| 180 |
|
| 181 |
function observe(callback: () => void) {
|
|
@@ -200,7 +251,9 @@ export function createFrontmatterStore(ydoc: Y.Doc) {
|
|
| 200 |
addAuthor,
|
| 201 |
updateAuthor,
|
| 202 |
removeAuthor,
|
|
|
|
| 203 |
addAffiliation,
|
|
|
|
| 204 |
observe,
|
| 205 |
};
|
| 206 |
}
|
|
|
|
| 169 |
}
|
| 170 |
}
|
| 171 |
|
| 172 |
+
/**
|
| 173 |
+
* Move an author from one position to another. `to` is the desired final
|
| 174 |
+
* index in the resulting array. No-op when out of range or unchanged.
|
| 175 |
+
* Authors don't reference each other, so no index remapping is needed.
|
| 176 |
+
*/
|
| 177 |
+
function moveAuthor(from: number, to: number) {
|
| 178 |
+
const len = yAuthors.length;
|
| 179 |
+
if (from === to || from < 0 || to < 0 || from >= len || to >= len) return;
|
| 180 |
+
ydoc.transact(() => {
|
| 181 |
+
const item = yAuthors.get(from);
|
| 182 |
+
yAuthors.delete(from, 1);
|
| 183 |
+
yAuthors.insert(to, [item]);
|
| 184 |
+
});
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
// --- Affiliations (concurrent-safe) ---
|
| 188 |
|
| 189 |
function addAffiliation(affiliation: Affiliation): number {
|
|
|
|
| 191 |
return yAffiliations.length; // 1-based index
|
| 192 |
}
|
| 193 |
|
| 194 |
+
/**
|
| 195 |
+
* Move an affiliation from one position to another. `to` is the desired
|
| 196 |
+
* final index in the resulting array.
|
| 197 |
+
*
|
| 198 |
+
* Authors reference affiliations by their 1-based position, so reordering
|
| 199 |
+
* the affiliation list would silently re-point every author's superscripts
|
| 200 |
+
* unless we remap. We compute the old->new position mapping the move
|
| 201 |
+
* implies and rewrite each author's `affiliations` accordingly, all inside
|
| 202 |
+
* a single Yjs transaction so collaborators see one atomic update.
|
| 203 |
+
*/
|
| 204 |
+
function moveAffiliation(from: number, to: number) {
|
| 205 |
+
const len = yAffiliations.length;
|
| 206 |
+
if (from === to || from < 0 || to < 0 || from >= len || to >= len) return;
|
| 207 |
+
ydoc.transact(() => {
|
| 208 |
+
const aff = yAffiliations.get(from);
|
| 209 |
+
yAffiliations.delete(from, 1);
|
| 210 |
+
yAffiliations.insert(to, [aff]);
|
| 211 |
+
|
| 212 |
+
// Replay the same move on the index sequence to derive old->new (1-based).
|
| 213 |
+
const order = Array.from({ length: len }, (_, i) => i);
|
| 214 |
+
const [moved] = order.splice(from, 1);
|
| 215 |
+
order.splice(to, 0, moved);
|
| 216 |
+
const oldToNew = new Map<number, number>();
|
| 217 |
+
order.forEach((oldPos, newPos) => oldToNew.set(oldPos + 1, newPos + 1));
|
| 218 |
+
|
| 219 |
+
const authors = yAuthors.toArray();
|
| 220 |
+
yAuthors.delete(0, yAuthors.length);
|
| 221 |
+
yAuthors.push(
|
| 222 |
+
authors.map((a) => ({
|
| 223 |
+
...a,
|
| 224 |
+
affiliations: (a.affiliations || []).map((idx) => oldToNew.get(idx) ?? idx),
|
| 225 |
+
})),
|
| 226 |
+
);
|
| 227 |
+
});
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
// --- Observe all changes ---
|
| 231 |
|
| 232 |
function observe(callback: () => void) {
|
|
|
|
| 251 |
addAuthor,
|
| 252 |
updateAuthor,
|
| 253 |
removeAuthor,
|
| 254 |
+
moveAuthor,
|
| 255 |
addAffiliation,
|
| 256 |
+
moveAffiliation,
|
| 257 |
observe,
|
| 258 |
};
|
| 259 |
}
|
frontend/src/styles/editor/_hero-editable.css
CHANGED
|
@@ -73,6 +73,22 @@
|
|
| 73 |
}
|
| 74 |
.author-editable:hover { background: var(--bg-hover); }
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
.author-add-btn {
|
| 77 |
display: inline-flex;
|
| 78 |
align-items: center;
|
|
|
|
| 73 |
}
|
| 74 |
.author-editable:hover { background: var(--bg-hover); }
|
| 75 |
|
| 76 |
+
/* ---- Drag-and-drop reordering (authors + affiliations) ---- */
|
| 77 |
+
/* Editor-only: lives in _hero-editable.css so the drop affordances never
|
| 78 |
+
leak into published HTML, which loads _hero.css but not this file. */
|
| 79 |
+
|
| 80 |
+
.author-item[draggable="true"] { cursor: grab; }
|
| 81 |
+
.author-item--dragging { opacity: 0.4; }
|
| 82 |
+
/* Authors flow horizontally (flex row), so the drop marker is a left edge. */
|
| 83 |
+
.author-item--dragover { box-shadow: inset 2px 0 0 0 var(--primary-color); }
|
| 84 |
+
|
| 85 |
+
.affiliations li[draggable="true"] { cursor: grab; }
|
| 86 |
+
.affiliations li.aff-item--dragging { opacity: 0.4; }
|
| 87 |
+
/* Affiliations stack vertically (ol), so the drop marker is a top edge. */
|
| 88 |
+
.affiliations li.aff-item--dragover {
|
| 89 |
+
box-shadow: inset 0 2px 0 0 var(--primary-color);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
.author-add-btn {
|
| 93 |
display: inline-flex;
|
| 94 |
align-items: center;
|