tfrere HF Staff Cursor commited on
Commit
a2d6d01
·
1 Parent(s): 2c3dcf7

feat(editor): drag-and-drop reordering for authors and affiliations

Browse files

Authors 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
- <li key={`author-${i}`}>
208
- <span
209
- className="author-editable"
210
- onClick={() => setEditingAuthorIdx(i)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  >
212
- {author.url ? (
213
- <a href={author.url} target="_blank" rel="noopener noreferrer">
214
- {author.name}
215
- </a>
216
- ) : (
217
- author.name
218
- )}
219
- {multipleAffiliations && author.affiliations?.length > 0 && (
220
- <sup>{author.affiliations.join(",")}</sup>
221
- )}
222
- </span>
223
- {i < data.authors.length - 1 && <>,&nbsp;</>}
224
- </li>
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
- <li key={`aff-${i}`}>
257
- {aff.url ? (
258
- <a href={aff.url} target="_blank" rel="noopener noreferrer">
259
- {aff.name}
260
- </a>
261
- ) : (
262
- aff.name
263
- )}
264
- </li>
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 && <>,&nbsp;</>}
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;