Spaces:
Sleeping
Sleeping
Commit ·
3f3e761
1
Parent(s): d3283b8
Left carets, home star, project subtitles, empty-group rename fix
Browse filesFold carets move to the left of the label (aligned spacer for leaf
rows); the hover trash overlays the row end so long titles run to the
edge. A muted star marks the home page. Renaming a group with no
children no longer loses its input — every group-label single-click
consequence now defers behind the same 280ms timer the double-click
cancels. The home page's first paragraph becomes the project's
subtitle (derived on store, persisted on the registry) and previews
under each title in the project dropdown.
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .e2e-collapsed.png +0 -0
- .e2e-dnd-data/docs/Mu8PCMYK.md +0 -2
- .e2e-dnd-data/docs/Mu8PCMYK.yjs +0 -0
- .e2e-dnd-data/docs/hUfaQZjM.md +3 -0
- .e2e-dnd-data/docs/hUfaQZjM.yjs +0 -0
- .e2e-dnd-data/registry.json +9 -8
- .e2e-dnd-data/session-secret +1 -1
- .e2e-dnd-data/snapshots/Mu8PCMYK/1785799048662.yjs +0 -0
- .e2e-dnd-data/snapshots/hUfaQZjM/1785800943780.yjs +0 -0
- .e2e-final.png +0 -0
- client/app.css +20 -14
- client/src/common.js +1 -0
- client/src/doc.js +22 -10
- server/api.js +4 -1
- server/collab.js +39 -6
- server/store.js +10 -1
- test/e2e-dnd.mjs +41 -1
.e2e-collapsed.png
CHANGED
|
|
.e2e-dnd-data/docs/Mu8PCMYK.md
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
# DnD e2e
|
| 2 |
-
|
|
|
|
|
|
|
|
|
.e2e-dnd-data/docs/Mu8PCMYK.yjs
DELETED
|
Binary file (827 Bytes)
|
|
|
.e2e-dnd-data/docs/hUfaQZjM.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DnD e2e
|
| 2 |
+
|
| 3 |
+
A tiny project used by the browser test.
|
.e2e-dnd-data/docs/hUfaQZjM.yjs
ADDED
|
Binary file (942 Bytes). View file
|
|
|
.e2e-dnd-data/registry.json
CHANGED
|
@@ -1,29 +1,30 @@
|
|
| 1 |
{
|
| 2 |
-
"
|
| 3 |
"title": "DnD e2e",
|
| 4 |
-
"createdAt":
|
| 5 |
-
"updatedAt":
|
| 6 |
"createdBy": "alice",
|
| 7 |
"pages": {
|
| 8 |
"_structure": {
|
| 9 |
"title": "Structure",
|
| 10 |
-
"createdAt":
|
| 11 |
},
|
| 12 |
"intro": {
|
| 13 |
"title": "Intro Renamed",
|
| 14 |
-
"createdAt":
|
| 15 |
"createdBy": "alice"
|
| 16 |
},
|
| 17 |
"notes": {
|
| 18 |
"title": "Notes",
|
| 19 |
-
"createdAt":
|
| 20 |
"createdBy": "alice"
|
| 21 |
},
|
| 22 |
"untitled": {
|
| 23 |
"title": "Fresh Page",
|
| 24 |
-
"createdAt":
|
| 25 |
"createdBy": "alice"
|
| 26 |
}
|
| 27 |
-
}
|
|
|
|
| 28 |
}
|
| 29 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"hUfaQZjM": {
|
| 3 |
"title": "DnD e2e",
|
| 4 |
+
"createdAt": 1785800941745,
|
| 5 |
+
"updatedAt": 1785800977161,
|
| 6 |
"createdBy": "alice",
|
| 7 |
"pages": {
|
| 8 |
"_structure": {
|
| 9 |
"title": "Structure",
|
| 10 |
+
"createdAt": 1785800941755
|
| 11 |
},
|
| 12 |
"intro": {
|
| 13 |
"title": "Intro Renamed",
|
| 14 |
+
"createdAt": 1785800941762,
|
| 15 |
"createdBy": "alice"
|
| 16 |
},
|
| 17 |
"notes": {
|
| 18 |
"title": "Notes",
|
| 19 |
+
"createdAt": 1785800941771,
|
| 20 |
"createdBy": "alice"
|
| 21 |
},
|
| 22 |
"untitled": {
|
| 23 |
"title": "Fresh Page",
|
| 24 |
+
"createdAt": 1785800954962,
|
| 25 |
"createdBy": "alice"
|
| 26 |
}
|
| 27 |
+
},
|
| 28 |
+
"subtitle": "A tiny project used by the browser test."
|
| 29 |
}
|
| 30 |
}
|
.e2e-dnd-data/session-secret
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
cc366cbee91eb082c31e73e023746c356916662d233098a2d4a5533b684cc451
|
.e2e-dnd-data/snapshots/Mu8PCMYK/1785799048662.yjs
DELETED
|
Binary file (533 Bytes)
|
|
|
.e2e-dnd-data/snapshots/hUfaQZjM/1785800943780.yjs
ADDED
|
Binary file (533 Bytes). View file
|
|
|
.e2e-final.png
CHANGED
|
|
client/app.css
CHANGED
|
@@ -254,6 +254,8 @@ textarea { resize: vertical; }
|
|
| 254 |
.doc-row.current { background: var(--accent-soft); }
|
| 255 |
.doc-row-main { display: flex; flex-direction: column; gap: 1px; padding: 7px 0; flex: 1; min-width: 0; text-decoration: none; color: var(--text); }
|
| 256 |
.doc-row .title { font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
|
|
|
| 257 |
.doc-row .meta { font-size: 11.5px; color: var(--text-3); }
|
| 258 |
.doc-row .iconbtn { opacity: 0; flex-shrink: 0; }
|
| 259 |
.doc-row:hover .iconbtn, .doc-row .iconbtn:focus-visible { opacity: 1; }
|
|
@@ -759,28 +761,32 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 759 |
#page-tree .nav-cat { margin-bottom: 10px; }
|
| 760 |
#page-tree .nav-cat .nav-cat { margin-bottom: 2px; }
|
| 761 |
#page-tree .tree-row {
|
| 762 |
-
|
|
|
|
| 763 |
}
|
| 764 |
#page-tree a {
|
| 765 |
display: block; flex: 1; min-width: 0; padding: 5px 9px; color: var(--text-2); font-size: 12.5px; line-height: 1.35; text-decoration: none;
|
| 766 |
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 767 |
transition: background .12s, color .12s;
|
| 768 |
}
|
| 769 |
-
#page-tree .page-row:hover {
|
| 770 |
#page-tree .page-row:hover > a { color: var(--text); }
|
| 771 |
-
#page-tree .page-row:has(a.current) {
|
|
|
|
|
|
|
|
|
|
| 772 |
#page-tree a.current { color: var(--accent); font-weight: 500; }
|
| 773 |
-
#page-tree .tree-row[data-selected='1']:not(:has(a.current)) {
|
| 774 |
#page-tree .tree-row[data-selected='1']:not(:has(a.current)) a,
|
| 775 |
#page-tree .group-row[data-selected='1'] .group-label { color: var(--text); }
|
| 776 |
#page-tree a.ghost-page { color: var(--text-3); font-style: italic; }
|
| 777 |
#page-tree .tree-caret,
|
| 778 |
#page-tree .tree-caret-spacer {
|
| 779 |
-
|
| 780 |
}
|
| 781 |
#page-tree .tree-caret {
|
| 782 |
color: var(--text-3); cursor: pointer; font-size: 18px; line-height: 26px; transform: rotate(0deg);
|
| 783 |
-
text-align: center; transition: transform .15s, color .12s;
|
| 784 |
}
|
| 785 |
#page-tree .tree-caret.collapsed { transform: rotate(-90deg); }
|
| 786 |
#page-tree .tree-caret:hover { color: var(--text); }
|
|
@@ -794,7 +800,8 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 794 |
#page-tree .group-row.lvl-2 .group-label { font-size: 12.5px; }
|
| 795 |
#page-tree .group-row.lvl-3 .group-label,
|
| 796 |
#page-tree .group-row.lvl-4 .group-label { color: var(--text-2); font-size: 12px; }
|
| 797 |
-
#page-tree .group-row:hover {
|
|
|
|
| 798 |
#page-tree .sidebar-rename {
|
| 799 |
flex: 1; min-width: 0; margin: 1px 3px; padding: 3px 5px; border: 1px solid var(--accent); border-radius: 5px;
|
| 800 |
background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
|
|
@@ -907,12 +914,11 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 907 |
#margin-toggle:hover { color: var(--text); }
|
| 908 |
|
| 909 |
|
| 910 |
-
/*
|
| 911 |
-
|
| 912 |
-
#page-tree .page-del, #page-tree .group-del, #page-tree .tree-delete-spacer {
|
| 913 |
-
order: 4; width: 22px; height: 26px; flex: 0 0 22px; margin: 0; padding: 0 2px;
|
| 914 |
-
}
|
| 915 |
#page-tree .page-del, #page-tree .group-del {
|
|
|
|
|
|
|
| 916 |
visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s, color .12s, background .12s;
|
| 917 |
}
|
| 918 |
#page-tree .tree-row:hover > .page-del, #page-tree .tree-row:hover > .group-del,
|
|
@@ -921,7 +927,7 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 921 |
}
|
| 922 |
#page-tree .page-del svg, #page-tree .group-del svg { width: 13px; height: 13px; }
|
| 923 |
#page-tree .page-del:hover, #page-tree .group-del:hover { color: var(--danger); }
|
| 924 |
-
#page-tree .page-del.arming, #page-tree .group-del.arming { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%,
|
| 925 |
|
| 926 |
|
| 927 |
/* --- slug legend on the structure page --- */
|
|
@@ -1262,7 +1268,7 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 1262 |
#page-tree a { padding: 10px 10px; border-radius: 8px; }
|
| 1263 |
#page-tree .tree-caret, #page-tree .tree-caret-spacer { width: 24px; height: 40px; flex-basis: 24px; line-height: 40px; }
|
| 1264 |
#page-tree .group-label { padding: 10px 6px; font-size: 13.5px; }
|
| 1265 |
-
#page-tree .page-del, #page-tree .group-del
|
| 1266 |
#page-tree .page-del, #page-tree .group-del { visibility: visible; opacity: 1; pointer-events: auto; padding: 6px; }
|
| 1267 |
#page-tree .page-del svg, #page-tree .group-del svg { width: 16px; height: 16px; }
|
| 1268 |
/* the desktop edge tabs are replaced by real header buttons */
|
|
|
|
| 254 |
.doc-row.current { background: var(--accent-soft); }
|
| 255 |
.doc-row-main { display: flex; flex-direction: column; gap: 1px; padding: 7px 0; flex: 1; min-width: 0; text-decoration: none; color: var(--text); }
|
| 256 |
.doc-row .title { font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 257 |
+
/* the project's subtitle (home page's first paragraph) — a description line */
|
| 258 |
+
.doc-row .doc-row-sub { font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 259 |
.doc-row .meta { font-size: 11.5px; color: var(--text-3); }
|
| 260 |
.doc-row .iconbtn { opacity: 0; flex-shrink: 0; }
|
| 261 |
.doc-row:hover .iconbtn, .doc-row .iconbtn:focus-visible { opacity: 1; }
|
|
|
|
| 761 |
#page-tree .nav-cat { margin-bottom: 10px; }
|
| 762 |
#page-tree .nav-cat .nav-cat { margin-bottom: 2px; }
|
| 763 |
#page-tree .tree-row {
|
| 764 |
+
--sidebar-row-bg: var(--bg);
|
| 765 |
+
position: relative; display: flex; align-items: center; min-width: 0; border-left: 2px solid transparent; border-radius: 5px;
|
| 766 |
}
|
| 767 |
#page-tree a {
|
| 768 |
display: block; flex: 1; min-width: 0; padding: 5px 9px; color: var(--text-2); font-size: 12.5px; line-height: 1.35; text-decoration: none;
|
| 769 |
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 770 |
transition: background .12s, color .12s;
|
| 771 |
}
|
| 772 |
+
#page-tree .page-row:hover { --sidebar-row-bg: var(--hl-weak); background: var(--sidebar-row-bg); }
|
| 773 |
#page-tree .page-row:hover > a { color: var(--text); }
|
| 774 |
+
#page-tree .page-row:has(a.current) {
|
| 775 |
+
--sidebar-row-bg: color-mix(in srgb, var(--accent) 12%, transparent);
|
| 776 |
+
border-left-color: var(--accent); background: var(--sidebar-row-bg);
|
| 777 |
+
}
|
| 778 |
#page-tree a.current { color: var(--accent); font-weight: 500; }
|
| 779 |
+
#page-tree .tree-row[data-selected='1']:not(:has(a.current)) { --sidebar-row-bg: var(--hl); background: var(--sidebar-row-bg); }
|
| 780 |
#page-tree .tree-row[data-selected='1']:not(:has(a.current)) a,
|
| 781 |
#page-tree .group-row[data-selected='1'] .group-label { color: var(--text); }
|
| 782 |
#page-tree a.ghost-page { color: var(--text-3); font-style: italic; }
|
| 783 |
#page-tree .tree-caret,
|
| 784 |
#page-tree .tree-caret-spacer {
|
| 785 |
+
width: 16px; height: 26px; flex: 0 0 16px; margin: 0; padding: 0; border: 0; background: transparent;
|
| 786 |
}
|
| 787 |
#page-tree .tree-caret {
|
| 788 |
color: var(--text-3); cursor: pointer; font-size: 18px; line-height: 26px; transform: rotate(0deg);
|
| 789 |
+
align-self: center; text-align: center; transition: transform .15s, color .12s;
|
| 790 |
}
|
| 791 |
#page-tree .tree-caret.collapsed { transform: rotate(-90deg); }
|
| 792 |
#page-tree .tree-caret:hover { color: var(--text); }
|
|
|
|
| 800 |
#page-tree .group-row.lvl-2 .group-label { font-size: 12.5px; }
|
| 801 |
#page-tree .group-row.lvl-3 .group-label,
|
| 802 |
#page-tree .group-row.lvl-4 .group-label { color: var(--text-2); font-size: 12px; }
|
| 803 |
+
#page-tree .group-row:hover { --sidebar-row-bg: var(--hl-weak); background: var(--sidebar-row-bg); }
|
| 804 |
+
#page-tree .home-star { width: 12px; height: 12px; flex: 0 0 12px; color: var(--text-3); }
|
| 805 |
#page-tree .sidebar-rename {
|
| 806 |
flex: 1; min-width: 0; margin: 1px 3px; padding: 3px 5px; border: 1px solid var(--accent); border-radius: 5px;
|
| 807 |
background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
|
|
|
|
| 914 |
#margin-toggle:hover { color: var(--text); }
|
| 915 |
|
| 916 |
|
| 917 |
+
/* Trash overlays the row's trailing edge instead of shortening every title.
|
| 918 |
+
Its opaque hover-matched fill lets text disappear cleanly underneath. */
|
|
|
|
|
|
|
|
|
|
| 919 |
#page-tree .page-del, #page-tree .group-del {
|
| 920 |
+
position: absolute; right: 0; top: 50%; z-index: 2; width: 22px; height: 26px; margin: 0; padding: 0 2px;
|
| 921 |
+
transform: translateY(-50%); background: var(--sidebar-row-bg);
|
| 922 |
visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s, color .12s, background .12s;
|
| 923 |
}
|
| 924 |
#page-tree .tree-row:hover > .page-del, #page-tree .tree-row:hover > .group-del,
|
|
|
|
| 927 |
}
|
| 928 |
#page-tree .page-del svg, #page-tree .group-del svg { width: 13px; height: 13px; }
|
| 929 |
#page-tree .page-del:hover, #page-tree .group-del:hover { color: var(--danger); }
|
| 930 |
+
#page-tree .page-del.arming, #page-tree .group-del.arming { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, var(--sidebar-row-bg)); }
|
| 931 |
|
| 932 |
|
| 933 |
/* --- slug legend on the structure page --- */
|
|
|
|
| 1268 |
#page-tree a { padding: 10px 10px; border-radius: 8px; }
|
| 1269 |
#page-tree .tree-caret, #page-tree .tree-caret-spacer { width: 24px; height: 40px; flex-basis: 24px; line-height: 40px; }
|
| 1270 |
#page-tree .group-label { padding: 10px 6px; font-size: 13.5px; }
|
| 1271 |
+
#page-tree .page-del, #page-tree .group-del { width: 30px; height: 40px; }
|
| 1272 |
#page-tree .page-del, #page-tree .group-del { visibility: visible; opacity: 1; pointer-events: auto; padding: 6px; }
|
| 1273 |
#page-tree .page-del svg, #page-tree .group-del svg { width: 16px; height: 16px; }
|
| 1274 |
/* the desktop edge tabs are replaced by real header buttons */
|
client/src/common.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
const ICONS = {
|
| 4 |
file: '<path d="M6 3.5h7l3 3v12H6z"/><path d="M13 3.5v3h3"/>',
|
| 5 |
folder: '<path d="M3.5 6.5h6l2-2h7v13h-15z"/>',
|
|
|
|
| 6 |
tree: '<path d="M4 5h14M9 11h9M9 17h9M4 5v9.5a2.5 2.5 0 0 0 2.5 2.5H6"/>',
|
| 7 |
key: '<circle cx="7.5" cy="14.5" r="3.5"/><path d="M10 12l8-8M14.5 4.5l3 3M12 7l2.5 2.5"/>',
|
| 8 |
todo: '<rect x="4" y="4" width="6" height="6" rx="1.5"/><path d="M5.2 7l1.3 1.3L8.8 6"/><path d="M13 6.5h6M13 11h6M13 15.5h6"/><rect x="4" y="12.5" width="6" height="6" rx="1.5"/>',
|
|
|
|
| 3 |
const ICONS = {
|
| 4 |
file: '<path d="M6 3.5h7l3 3v12H6z"/><path d="M13 3.5v3h3"/>',
|
| 5 |
folder: '<path d="M3.5 6.5h6l2-2h7v13h-15z"/>',
|
| 6 |
+
star: '<path d="M11 3.5l2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7z"/>',
|
| 7 |
tree: '<path d="M4 5h14M9 11h9M9 17h9M4 5v9.5a2.5 2.5 0 0 0 2.5 2.5H6"/>',
|
| 8 |
key: '<circle cx="7.5" cy="14.5" r="3.5"/><path d="M10 12l8-8M14.5 4.5l3 3M12 7l2.5 2.5"/>',
|
| 9 |
todo: '<rect x="4" y="4" width="6" height="6" rx="1.5"/><path d="M5.2 7l1.3 1.3L8.8 6"/><path d="M13 6.5h6M13 11h6M13 15.5h6"/><rect x="4" y="12.5" width="6" height="6" rx="1.5"/>',
|
client/src/doc.js
CHANGED
|
@@ -1608,10 +1608,13 @@ async function loadStructure() {
|
|
| 1608 |
let clickTimer = null
|
| 1609 |
label.addEventListener('click', e => {
|
| 1610 |
clearTimeout(clickTimer)
|
| 1611 |
-
const
|
| 1612 |
-
|
| 1613 |
-
|
| 1614 |
-
|
|
|
|
|
|
|
|
|
|
| 1615 |
})
|
| 1616 |
label.addEventListener('dblclick', e => {
|
| 1617 |
e.preventDefault()
|
|
@@ -1639,9 +1642,10 @@ async function loadStructure() {
|
|
| 1639 |
if (e.button !== 0) return
|
| 1640 |
e.preventDefault()
|
| 1641 |
clearTimeout(clickTimer)
|
| 1642 |
-
const
|
| 1643 |
-
if (!plain) return loadStructure()
|
| 1644 |
clickTimer = setTimeout(() => {
|
|
|
|
|
|
|
| 1645 |
if (node.slug === pageSlug) loadStructure()
|
| 1646 |
else openPage(node.slug)
|
| 1647 |
}, 280)
|
|
@@ -1653,9 +1657,13 @@ async function loadStructure() {
|
|
| 1653 |
if (!known.has(node.slug)) return
|
| 1654 |
beginPageRename(a, node.slug, pageTitle)
|
| 1655 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1656 |
row.appendChild(a)
|
| 1657 |
if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
|
| 1658 |
-
else row.appendChild(el('span', { class: 'tree-delete-spacer', 'aria-hidden': 'true' }))
|
| 1659 |
}
|
| 1660 |
li.appendChild(row)
|
| 1661 |
if (hasChildren && !isCollapsed) li.appendChild(renderNodes(node.children, refPath, key, depth + 1))
|
|
@@ -1686,9 +1694,10 @@ async function loadStructure() {
|
|
| 1686 |
if (e.button !== 0) return
|
| 1687 |
e.preventDefault()
|
| 1688 |
clearTimeout(clickTimer)
|
| 1689 |
-
const
|
| 1690 |
-
if (!plain) return loadStructure()
|
| 1691 |
clickTimer = setTimeout(() => {
|
|
|
|
|
|
|
| 1692 |
if (slug === pageSlug) loadStructure()
|
| 1693 |
else openPage(slug)
|
| 1694 |
}, 280)
|
|
@@ -1701,7 +1710,6 @@ async function loadStructure() {
|
|
| 1701 |
})
|
| 1702 |
row.appendChild(a)
|
| 1703 |
if (deletablePage(slug)) row.appendChild(pageDeleteBtn(slug))
|
| 1704 |
-
else row.appendChild(el('span', { class: 'tree-delete-spacer', 'aria-hidden': 'true' }))
|
| 1705 |
li.appendChild(row)
|
| 1706 |
ul.appendChild(li)
|
| 1707 |
}
|
|
@@ -2951,6 +2959,7 @@ function openComposer(editor, opts = {}) {
|
|
| 2951 |
// There is no separate index page: the document you are in is the whole app, and
|
| 2952 |
// this is how you reach the others. Switching is a normal navigation — the shell
|
| 2953 |
// is cheap and it keeps one document per URL, which is what sharing a link means.
|
|
|
|
| 2954 |
function wireDocsMenu() {
|
| 2955 |
const btn = document.getElementById('docs-btn')
|
| 2956 |
const pop = document.getElementById('docs-pop')
|
|
@@ -2970,6 +2979,9 @@ function wireDocsMenu() {
|
|
| 2970 |
const row = el('div', { class: 'doc-row' + (d.id === docId ? ' current' : '') })
|
| 2971 |
const link = el('a', { href: `/d/${d.id}`, class: 'doc-row-main' })
|
| 2972 |
link.appendChild(el('span', { class: 'title' }, d.title || 'Untitled'))
|
|
|
|
|
|
|
|
|
|
| 2973 |
const who = d.createdBy && d.createdBy !== state.me?.username ? `${d.createdBy} · ` : ''
|
| 2974 |
// timeAgo says 'now' for anything inside a minute, which does not take 'ago'
|
| 2975 |
const when = timeAgo(d.updatedAt)
|
|
|
|
| 1608 |
let clickTimer = null
|
| 1609 |
label.addEventListener('click', e => {
|
| 1610 |
clearTimeout(clickTimer)
|
| 1611 |
+
const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey }
|
| 1612 |
+
clickTimer = setTimeout(() => {
|
| 1613 |
+
const plain = updateSelection(modifiers, key)
|
| 1614 |
+
if (!plain) return loadStructure()
|
| 1615 |
+
if (hasChildren) setSidebarCollapsed(key, !isCollapsed)
|
| 1616 |
+
else loadStructure()
|
| 1617 |
+
}, 280)
|
| 1618 |
})
|
| 1619 |
label.addEventListener('dblclick', e => {
|
| 1620 |
e.preventDefault()
|
|
|
|
| 1642 |
if (e.button !== 0) return
|
| 1643 |
e.preventDefault()
|
| 1644 |
clearTimeout(clickTimer)
|
| 1645 |
+
const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey }
|
|
|
|
| 1646 |
clickTimer = setTimeout(() => {
|
| 1647 |
+
const plain = updateSelection(modifiers, key)
|
| 1648 |
+
if (!plain) return loadStructure()
|
| 1649 |
if (node.slug === pageSlug) loadStructure()
|
| 1650 |
else openPage(node.slug)
|
| 1651 |
}, 280)
|
|
|
|
| 1657 |
if (!known.has(node.slug)) return
|
| 1658 |
beginPageRename(a, node.slug, pageTitle)
|
| 1659 |
})
|
| 1660 |
+
if (node.slug === 'home') {
|
| 1661 |
+
const star = icon('star')
|
| 1662 |
+
star.classList.add('home-star')
|
| 1663 |
+
row.appendChild(star)
|
| 1664 |
+
}
|
| 1665 |
row.appendChild(a)
|
| 1666 |
if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
|
|
|
|
| 1667 |
}
|
| 1668 |
li.appendChild(row)
|
| 1669 |
if (hasChildren && !isCollapsed) li.appendChild(renderNodes(node.children, refPath, key, depth + 1))
|
|
|
|
| 1694 |
if (e.button !== 0) return
|
| 1695 |
e.preventDefault()
|
| 1696 |
clearTimeout(clickTimer)
|
| 1697 |
+
const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey }
|
|
|
|
| 1698 |
clickTimer = setTimeout(() => {
|
| 1699 |
+
const plain = updateSelection(modifiers, slug)
|
| 1700 |
+
if (!plain) return loadStructure()
|
| 1701 |
if (slug === pageSlug) loadStructure()
|
| 1702 |
else openPage(slug)
|
| 1703 |
}, 280)
|
|
|
|
| 1710 |
})
|
| 1711 |
row.appendChild(a)
|
| 1712 |
if (deletablePage(slug)) row.appendChild(pageDeleteBtn(slug))
|
|
|
|
| 1713 |
li.appendChild(row)
|
| 1714 |
ul.appendChild(li)
|
| 1715 |
}
|
|
|
|
| 2959 |
// There is no separate index page: the document you are in is the whole app, and
|
| 2960 |
// this is how you reach the others. Switching is a normal navigation — the shell
|
| 2961 |
// is cheap and it keeps one document per URL, which is what sharing a link means.
|
| 2962 |
+
|
| 2963 |
function wireDocsMenu() {
|
| 2964 |
const btn = document.getElementById('docs-btn')
|
| 2965 |
const pop = document.getElementById('docs-pop')
|
|
|
|
| 2979 |
const row = el('div', { class: 'doc-row' + (d.id === docId ? ' current' : '') })
|
| 2980 |
const link = el('a', { href: `/d/${d.id}`, class: 'doc-row-main' })
|
| 2981 |
link.appendChild(el('span', { class: 'title' }, d.title || 'Untitled'))
|
| 2982 |
+
// how the project itself opens, so the list says what each one is about.
|
| 2983 |
+
// Nothing written under home's title yet = no second line at all.
|
| 2984 |
+
if (d.subtitle) link.appendChild(el('span', { class: 'doc-row-sub' }, d.subtitle))
|
| 2985 |
const who = d.createdBy && d.createdBy !== state.me?.username ? `${d.createdBy} · ` : ''
|
| 2986 |
// timeAgo says 'now' for anything inside a minute, which does not take 'ago'
|
| 2987 |
const when = timeAgo(d.updatedAt)
|
server/api.js
CHANGED
|
@@ -40,7 +40,9 @@ export function apiRouter() {
|
|
| 40 |
const reg = store.getRegistry()
|
| 41 |
const docs = Object.entries(reg)
|
| 42 |
.filter(([id]) => store.canAccessDoc(id, req.user.username))
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
| 45 |
res.json({ docs })
|
| 46 |
})
|
|
@@ -90,6 +92,7 @@ export function apiRouter() {
|
|
| 90 |
res.json({
|
| 91 |
id: req.params.id,
|
| 92 |
title: meta.title,
|
|
|
|
| 93 |
page: docName.includes('::') ? docName.split('::')[1] : 'home',
|
| 94 |
pages: Object.entries(pages).map(([slug, p]) => ({ slug, title: p.title || slug })),
|
| 95 |
created_by: meta.createdBy || null,
|
|
|
|
| 40 |
const reg = store.getRegistry()
|
| 41 |
const docs = Object.entries(reg)
|
| 42 |
.filter(([id]) => store.canAccessDoc(id, req.user.username))
|
| 43 |
+
// subtitle spelled out: the switcher shows it under each title, and a
|
| 44 |
+
// project that has none must still say so rather than leave the key off
|
| 45 |
+
.map(([id, meta]) => ({ id, ...meta, subtitle: meta.subtitle || null }))
|
| 46 |
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
| 47 |
res.json({ docs })
|
| 48 |
})
|
|
|
|
| 92 |
res.json({
|
| 93 |
id: req.params.id,
|
| 94 |
title: meta.title,
|
| 95 |
+
subtitle: meta.subtitle || null,
|
| 96 |
page: docName.includes('::') ? docName.split('::')[1] : 'home',
|
| 97 |
pages: Object.entries(pages).map(([slug, p]) => ({ slug, title: p.title || slug })),
|
| 98 |
created_by: meta.createdBy || null,
|
server/collab.js
CHANGED
|
@@ -195,27 +195,60 @@ export const hocuspocus = new Hocuspocus({
|
|
| 195 |
let homeMarkdown = null
|
| 196 |
for (const name of projectPageNames(projectId)) {
|
| 197 |
try {
|
|
|
|
| 198 |
const pm = yDocToProsemirrorJSON(document, pageField(name))
|
| 199 |
-
if (
|
| 200 |
-
|
|
|
|
| 201 |
} catch {}
|
| 202 |
}
|
| 203 |
store.saveDocState(projectId, Y.encodeStateAsUpdate(document), homeMarkdown)
|
| 204 |
},
|
| 205 |
})
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
| 210 |
?.replace(/^#+\s*/, '')
|
|
|
|
| 211 |
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> their text
|
| 212 |
.replace(/[*_`]/g, '')
|
| 213 |
-
.trim()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
if (text) return text.slice(0, 80)
|
| 215 |
}
|
| 216 |
return null
|
| 217 |
}
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
// --- document watcher: detect new @mentions in comment threads ---
|
| 220 |
|
| 221 |
// Watch the LIVE document instance, not just the name. A document can be
|
|
|
|
| 195 |
let homeMarkdown = null
|
| 196 |
for (const name of projectPageNames(projectId)) {
|
| 197 |
try {
|
| 198 |
+
const slug = pageSlugOf(name)
|
| 199 |
const pm = yDocToProsemirrorJSON(document, pageField(name))
|
| 200 |
+
if (slug === 'home') homeMarkdown = pmToMarkdown(pm)
|
| 201 |
+
// the subtitle only describes the project, so only home has one
|
| 202 |
+
store.touchProjectOnStore(projectId, slug, titleFromPm(pm), slug === 'home' ? subtitleFromPm(pm) : null)
|
| 203 |
} catch {}
|
| 204 |
}
|
| 205 |
store.saveDocState(projectId, Y.encodeStateAsUpdate(document), homeMarkdown)
|
| 206 |
},
|
| 207 |
})
|
| 208 |
|
| 209 |
+
// The plain text of a ProseMirror block, with the markup that markdown adds
|
| 210 |
+
// around it taken back off — what a title or a subtitle reads as, not how it is
|
| 211 |
+
// written. (blockPlainText below is the same idea for *parsed markdown* blocks.)
|
| 212 |
+
function pmBlockText(node) {
|
| 213 |
+
return (
|
| 214 |
+
blockToMarkdown(node)
|
| 215 |
?.replace(/^#+\s*/, '')
|
| 216 |
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> their alt text, if any
|
| 217 |
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> their text
|
| 218 |
.replace(/[*_`]/g, '')
|
| 219 |
+
.trim() || ''
|
| 220 |
+
)
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function titleFromPm(pm) {
|
| 224 |
+
for (const node of pm.content || []) {
|
| 225 |
+
const text = pmBlockText(node)
|
| 226 |
if (text) return text.slice(0, 80)
|
| 227 |
}
|
| 228 |
return null
|
| 229 |
}
|
| 230 |
|
| 231 |
+
// The project's one-line description: the first ordinary paragraph under the
|
| 232 |
+
// title, the way the page itself opens. Derived like the title — read-only, on
|
| 233 |
+
// store, never written back into the document. Section headings, code, lists and
|
| 234 |
+
// tables are not prose, so they are passed over rather than ending the search.
|
| 235 |
+
const SUBTITLE_MAX = 160
|
| 236 |
+
|
| 237 |
+
function subtitleFromPm(pm) {
|
| 238 |
+
let pastTitle = false
|
| 239 |
+
for (const node of pm.content || []) {
|
| 240 |
+
const text = pmBlockText(node)
|
| 241 |
+
if (!text) continue
|
| 242 |
+
if (!pastTitle) {
|
| 243 |
+
pastTitle = true // whatever it was, that block is the title
|
| 244 |
+
continue
|
| 245 |
+
}
|
| 246 |
+
if (node.type !== 'paragraph') continue
|
| 247 |
+
return text.slice(0, SUBTITLE_MAX).trim()
|
| 248 |
+
}
|
| 249 |
+
return null
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
// --- document watcher: detect new @mentions in comment threads ---
|
| 253 |
|
| 254 |
// Watch the LIVE document instance, not just the name. A document can be
|
server/store.js
CHANGED
|
@@ -145,7 +145,7 @@ export function canAccessDoc(docName, username) {
|
|
| 145 |
// One combined registry touch per doc store, and only when something actually
|
| 146 |
// changed — registry.json lives on bucket-mounted storage where every sync
|
| 147 |
// write is expensive (and a hung write blocks the event loop).
|
| 148 |
-
export function touchProjectOnStore(projectId, slug, title) {
|
| 149 |
const reg = getRegistry()
|
| 150 |
const meta = reg[projectId]
|
| 151 |
if (!meta) return
|
|
@@ -157,6 +157,15 @@ export function touchProjectOnStore(projectId, slug, title) {
|
|
| 157 |
next.title = title || 'Untitled'
|
| 158 |
dirty = true
|
| 159 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
} else {
|
| 161 |
const pageTitle = slug === '_structure' ? 'Structure' : title || slug
|
| 162 |
if (meta.pages?.[slug]?.title !== pageTitle) {
|
|
|
|
| 145 |
// One combined registry touch per doc store, and only when something actually
|
| 146 |
// changed — registry.json lives on bucket-mounted storage where every sync
|
| 147 |
// write is expensive (and a hung write blocks the event loop).
|
| 148 |
+
export function touchProjectOnStore(projectId, slug, title, subtitle) {
|
| 149 |
const reg = getRegistry()
|
| 150 |
const meta = reg[projectId]
|
| 151 |
if (!meta) return
|
|
|
|
| 157 |
next.title = title || 'Untitled'
|
| 158 |
dirty = true
|
| 159 |
}
|
| 160 |
+
// the project's one-line description, from home's opening paragraph. Absent
|
| 161 |
+
// (the paragraph was emptied or never written) drops the key rather than
|
| 162 |
+
// storing a null — a write either way, but only when it really changed.
|
| 163 |
+
const sub = subtitle || null
|
| 164 |
+
if (sub !== (meta.subtitle || null)) {
|
| 165 |
+
if (sub) next.subtitle = sub
|
| 166 |
+
else delete next.subtitle
|
| 167 |
+
dirty = true
|
| 168 |
+
}
|
| 169 |
} else {
|
| 170 |
const pageTitle = slug === '_structure' ? 'Structure' : title || slug
|
| 171 |
if (meta.pages?.[slug]?.title !== pageTitle) {
|
test/e2e-dnd.mjs
CHANGED
|
@@ -236,8 +236,48 @@ try {
|
|
| 236 |
assert.ok(!st.tree.some(n => n.group === 'Extras'), 'armed second click dissolves')
|
| 237 |
}
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
await page.screenshot({ path: path.join(root, '.e2e-final.png') })
|
| 240 |
-
console.log('E2E DND TESTS PASSED (rounds 1
|
| 241 |
console.log(st.raw)
|
| 242 |
} finally {
|
| 243 |
await browser?.close()
|
|
|
|
| 236 |
assert.ok(!st.tree.some(n => n.group === 'Extras'), 'armed second click dissolves')
|
| 237 |
}
|
| 238 |
|
| 239 |
+
// --- round 4: caret left, home star, empty-group rename, subtitle ---
|
| 240 |
+
|
| 241 |
+
// 19. caret sits LEFT of the label; home row carries the star
|
| 242 |
+
{
|
| 243 |
+
const order = await page.evaluate(() => {
|
| 244 |
+
const row = document.querySelector('#page-tree [data-key="home"]')
|
| 245 |
+
return [...row.children].map(c => c.className && String(c.className.baseVal ?? c.className).split(' ')[0])
|
| 246 |
+
})
|
| 247 |
+
const caretAt = order.findIndex(c => c && c.startsWith('tree-caret'))
|
| 248 |
+
const starAt = order.findIndex(c => c === 'icon' || c === 'home-star')
|
| 249 |
+
const labelAt = order.findIndex(c => !c || c === 'current' || c === '')
|
| 250 |
+
assert.ok(caretAt === 0, 'caret/spacer first in the row: ' + JSON.stringify(order))
|
| 251 |
+
assert.ok(await page.locator('#page-tree [data-key="home"] .home-star').count(), 'home star present')
|
| 252 |
+
assert.ok(starAt < labelAt || labelAt === -1, 'star before label: ' + JSON.stringify(order))
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// 20. renaming a group with NO children: the input must survive the double-click
|
| 256 |
+
await api(`/api/docs/${doc.id}/structure/groups`, { label: 'Lonely' })
|
| 257 |
+
await page.waitForTimeout(1200)
|
| 258 |
+
await page.dblclick(`#page-tree [data-key='"Lonely"'] .group-label`)
|
| 259 |
+
await page.waitForTimeout(600) // long enough for any stray re-render to kill it
|
| 260 |
+
const lonelyInput = page.locator('#page-tree input.sidebar-rename')
|
| 261 |
+
assert.ok(await lonelyInput.count(), 'rename input still open on an empty group')
|
| 262 |
+
await lonelyInput.fill('Kept')
|
| 263 |
+
await lonelyInput.press('Enter')
|
| 264 |
+
await page.waitForTimeout(700)
|
| 265 |
+
st = await api(`/api/docs/${doc.id}/structure`)
|
| 266 |
+
assert.ok(st.tree.some(n => n.group === 'Kept'), 'empty group renamed: ' + JSON.stringify(st.tree))
|
| 267 |
+
|
| 268 |
+
// 21. home subtitle: first paragraph under the H1 previews in the project dropdown
|
| 269 |
+
await page.click('#page-tree [data-key="home"] a')
|
| 270 |
+
await page.waitForTimeout(800)
|
| 271 |
+
await page.evaluate(() => window.__editor.commands.focus('end'))
|
| 272 |
+
await page.keyboard.type('A tiny project used by the browser test.')
|
| 273 |
+
await page.waitForTimeout(4000) // store debounce persists the registry subtitle
|
| 274 |
+
await page.click('#docs-btn')
|
| 275 |
+
await page.waitForSelector('.doc-row', { timeout: 5000 })
|
| 276 |
+
const sub = await page.locator('.doc-row .doc-row-sub').first().textContent()
|
| 277 |
+
assert.ok(sub.includes('tiny project'), 'subtitle previews in the dropdown: ' + sub)
|
| 278 |
+
|
| 279 |
await page.screenshot({ path: path.join(root, '.e2e-final.png') })
|
| 280 |
+
console.log('E2E DND TESTS PASSED (rounds 1-4)')
|
| 281 |
console.log(st.raw)
|
| 282 |
} finally {
|
| 283 |
await browser?.close()
|