Skip to content

Commit 9ffa9df

Browse files
committed
Add selection popover for annotations
1 parent 8db50ac commit 9ffa9df

3 files changed

Lines changed: 236 additions & 42 deletions

File tree

assets/site.js

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,8 @@ const state = {
612612
focusedChapterIndex: null,
613613
annotations: [],
614614
activeAnnotationId: null,
615+
annotationRange: null,
616+
annotationStatusTimer: null,
615617
observer: null,
616618
guidePromise: null,
617619
};
@@ -627,6 +629,7 @@ const els = {
627629
plannerView: document.querySelector("#planner-view"),
628630
scheduleView: document.querySelector("#schedule-view"),
629631
readerView: document.querySelector("#reader-view"),
632+
readerShell: document.querySelector(".reader-shell"),
630633
plannerNav: document.querySelector("#planner-nav"),
631634
readerNav: document.querySelector("#reader-nav"),
632635
modeButtons: [...document.querySelectorAll(".view-toggle button[data-mode]")],
@@ -704,6 +707,7 @@ const els = {
704707
nextChapterTitle: document.querySelector("#next-chapter-title"),
705708
annotationKicker: document.querySelector("#annotation-kicker"),
706709
annotationTitle: document.querySelector("#annotation-title"),
710+
annotationPopover: document.querySelector("#annotation-popover"),
707711
annotationStatus: document.querySelector("#annotation-status"),
708712
annotationList: document.querySelector("#annotation-list"),
709713
highlightButton: document.querySelector("#highlight-button"),
@@ -984,6 +988,9 @@ function setLanguageChrome(lang) {
984988
els.nextChapterKicker.textContent = text.nextChapterKicker;
985989
els.annotationKicker.textContent = text.annotationKicker;
986990
els.annotationTitle.textContent = text.annotationTitle;
991+
els.annotationPopover.setAttribute("aria-label", text.annotationTitle);
992+
clearAnnotationStatusTimer();
993+
els.annotationStatus.classList.remove("is-visible");
987994
els.annotationStatus.textContent = text.annotationStatus;
988995
[
989996
[els.highlightButton, text.annotationHighlight],
@@ -1008,6 +1015,7 @@ function setLanguageChrome(lang) {
10081015
function setMode(mode, shouldUpdateUrl = true) {
10091016
const modes = new Set(["planner", "schedule", "reader"]);
10101017
state.mode = modes.has(mode) ? mode : "planner";
1018+
hideAnnotationPopover();
10111019
els.body.dataset.mode = state.mode;
10121020
els.plannerView.hidden = state.mode !== "planner";
10131021
els.scheduleView.hidden = state.mode !== "schedule";
@@ -1168,6 +1176,7 @@ function applyReaderFocus() {
11681176
function setReaderFocus(chapterIndex) {
11691177
const next = Number(chapterIndex);
11701178
state.focusedChapterIndex = Number.isInteger(next) && next >= 1 && next <= CHAPTER_COUNT ? next : null;
1179+
hideAnnotationPopover();
11711180
applyReaderFocus();
11721181
renderToc();
11731182
observeHeadings();
@@ -1179,10 +1188,111 @@ function cssEscape(value) {
11791188
return window.CSS?.escape ? CSS.escape(value) : String(value).replace(/["\\]/g, "\\$&");
11801189
}
11811190

1191+
function clearAnnotationStatusTimer() {
1192+
if (state.annotationStatusTimer) {
1193+
window.clearTimeout(state.annotationStatusTimer);
1194+
state.annotationStatusTimer = null;
1195+
}
1196+
}
1197+
11821198
function setAnnotationStatus(message) {
11831199
if (els.annotationStatus) {
1184-
els.annotationStatus.textContent = message || copy[state.lang].annotationStatus;
1200+
clearAnnotationStatusTimer();
1201+
const fallback = copy[state.lang].annotationStatus;
1202+
const nextMessage = message || fallback;
1203+
els.annotationStatus.textContent = nextMessage;
1204+
els.annotationStatus.classList.toggle("is-visible", Boolean(message));
1205+
1206+
if (message) {
1207+
state.annotationStatusTimer = window.setTimeout(() => {
1208+
els.annotationStatus.classList.remove("is-visible");
1209+
els.annotationStatus.textContent = fallback;
1210+
state.annotationStatusTimer = null;
1211+
}, 2200);
1212+
}
1213+
}
1214+
}
1215+
1216+
function selectionRangeInArticle() {
1217+
const selection = window.getSelection();
1218+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
1219+
1220+
const range = selection.getRangeAt(0);
1221+
if (!range.toString().trim()) return null;
1222+
if (!els.article.contains(range.commonAncestorContainer)) return null;
1223+
1224+
return range;
1225+
}
1226+
1227+
function selectedRangeRect(range) {
1228+
const rects = [...range.getClientRects()].filter((rect) => rect.width > 0 && rect.height > 0);
1229+
return rects[0] || range.getBoundingClientRect();
1230+
}
1231+
1232+
function hideAnnotationPopover(options = {}) {
1233+
if (els.annotationPopover) {
1234+
els.annotationPopover.hidden = true;
1235+
els.annotationPopover.classList.remove("is-below");
1236+
}
1237+
1238+
if (options.keepRange) return;
1239+
state.annotationRange = null;
1240+
}
1241+
1242+
function positionAnnotationPopover(range) {
1243+
if (!els.annotationPopover || !range) return;
1244+
1245+
const rect = selectedRangeRect(range);
1246+
if (!rect || (rect.width <= 0 && rect.height <= 0)) {
1247+
hideAnnotationPopover();
1248+
return;
1249+
}
1250+
1251+
els.annotationPopover.hidden = false;
1252+
els.annotationPopover.classList.remove("is-below");
1253+
1254+
const popoverRect = els.annotationPopover.getBoundingClientRect();
1255+
const inset = 10;
1256+
const left = Math.min(
1257+
window.innerWidth - popoverRect.width / 2 - inset,
1258+
Math.max(popoverRect.width / 2 + inset, rect.left + rect.width / 2),
1259+
);
1260+
const shouldPlaceBelow = rect.top < popoverRect.height + 18;
1261+
const top = shouldPlaceBelow ? rect.bottom : rect.top;
1262+
1263+
els.annotationPopover.classList.toggle("is-below", shouldPlaceBelow);
1264+
els.annotationPopover.style.left = `${left}px`;
1265+
els.annotationPopover.style.top = `${Math.max(inset, top)}px`;
1266+
}
1267+
1268+
function updateAnnotationPopoverFromSelection() {
1269+
if (state.mode !== "reader" || els.article.classList.contains("loading")) {
1270+
hideAnnotationPopover();
1271+
return;
1272+
}
1273+
1274+
const range = selectionRangeInArticle();
1275+
if (!range) {
1276+
hideAnnotationPopover();
1277+
return;
1278+
}
1279+
1280+
state.annotationRange = range.cloneRange();
1281+
positionAnnotationPopover(range);
1282+
}
1283+
1284+
function scheduleAnnotationPopoverUpdate() {
1285+
window.setTimeout(updateAnnotationPopoverFromSelection, 0);
1286+
}
1287+
1288+
function annotationRangeForAction() {
1289+
const liveRange = selectionRangeInArticle();
1290+
if (liveRange) {
1291+
state.annotationRange = liveRange.cloneRange();
1292+
return liveRange.cloneRange();
11851293
}
1294+
1295+
return state.annotationRange ? state.annotationRange.cloneRange() : null;
11861296
}
11871297

11881298
function annotationStyleLabel(style) {
@@ -1377,15 +1487,9 @@ function renderAnnotationList() {
13771487
const text = copy[state.lang];
13781488
const annotations = annotationsForCurrentRange().sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
13791489
els.annotationList.innerHTML = "";
1490+
els.readerShell?.classList.toggle("has-annotations", annotations.length > 0);
13801491

13811492
if (!annotations.length) {
1382-
const empty = document.createElement("div");
1383-
empty.className = "annotation-card";
1384-
const message = document.createElement("p");
1385-
message.className = "annotation-card-comment";
1386-
message.textContent = text.annotationEmpty;
1387-
empty.append(message);
1388-
els.annotationList.append(empty);
13891493
return;
13901494
}
13911495

@@ -1453,13 +1557,12 @@ function renderAnnotations() {
14531557

14541558
function createAnnotation(style) {
14551559
const text = copy[state.lang];
1456-
const selection = window.getSelection();
1457-
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
1560+
const range = annotationRangeForAction();
1561+
if (!range || range.collapsed) {
14581562
setAnnotationStatus(text.annotationNeedSelection);
14591563
return;
14601564
}
14611565

1462-
const range = selection.getRangeAt(0);
14631566
if (!els.article.contains(range.commonAncestorContainer)) {
14641567
setAnnotationStatus(text.annotationOutside);
14651568
return;
@@ -1514,7 +1617,9 @@ function createAnnotation(style) {
15141617
state.annotations.push(annotation);
15151618
state.activeAnnotationId = annotation.id;
15161619
writeAnnotations();
1517-
selection.removeAllRanges();
1620+
const selection = window.getSelection();
1621+
selection?.removeAllRanges();
1622+
hideAnnotationPopover();
15181623
renderAnnotations();
15191624
setAnnotationStatus(text.annotationSaved);
15201625
}
@@ -2648,13 +2753,26 @@ els.exportMatrixPdf.addEventListener("click", exportSchedulePdf);
26482753
els.resetMatrix.addEventListener("click", resetMatrixProgress);
26492754
els.search.addEventListener("input", renderToc);
26502755
els.top.addEventListener("click", () => window.scrollTo({ top: 0, behavior: "smooth" }));
2756+
els.annotationPopover.addEventListener("mousedown", (event) => event.preventDefault());
2757+
els.annotationPopover.addEventListener("click", (event) => event.stopPropagation());
26512758
els.highlightButton.addEventListener("click", () => createAnnotation("highlight"));
26522759
els.underlineButton.addEventListener("click", () => createAnnotation("underline"));
26532760
els.commentButton.addEventListener("click", () => createAnnotation("comment"));
2654-
window.addEventListener("scroll", updateReadingProgress, { passive: true });
2761+
document.addEventListener("selectionchange", scheduleAnnotationPopoverUpdate);
2762+
document.addEventListener("mouseup", scheduleAnnotationPopoverUpdate);
2763+
document.addEventListener("keyup", scheduleAnnotationPopoverUpdate);
2764+
document.addEventListener("pointerdown", (event) => {
2765+
if (event.target.closest("#annotation-popover") || event.target.closest("#guide-content")) return;
2766+
hideAnnotationPopover();
2767+
});
2768+
window.addEventListener("scroll", () => {
2769+
updateReadingProgress();
2770+
hideAnnotationPopover();
2771+
}, { passive: true });
26552772
window.addEventListener("resize", () => {
26562773
updateFixedChromeMetrics();
26572774
updateReadingProgress();
2775+
hideAnnotationPopover();
26582776
});
26592777
window.addEventListener("load", updateFixedChromeMetrics);
26602778
window.visualViewport?.addEventListener("resize", updateFixedChromeMetrics);

assets/styles.css

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,10 @@ button:disabled {
10751075
align-items: start;
10761076
}
10771077

1078+
.reader-shell:not(.has-annotations) {
1079+
grid-template-columns: minmax(0, var(--reader-content));
1080+
}
1081+
10781082
.reader-column {
10791083
min-width: 0;
10801084
}
@@ -1263,6 +1267,10 @@ button:disabled {
12631267
backdrop-filter: blur(14px);
12641268
}
12651269

1270+
.reader-shell:not(.has-annotations) .annotation-panel {
1271+
display: none;
1272+
}
1273+
12661274
.annotation-panel-head {
12671275
display: grid;
12681276
gap: 0.25rem;
@@ -1274,24 +1282,92 @@ button:disabled {
12741282
line-height: 1.2;
12751283
}
12761284

1277-
.annotation-tools {
1278-
display: grid;
1279-
grid-template-columns: repeat(3, minmax(0, 1fr));
1280-
gap: 0.45rem;
1285+
.annotation-status {
1286+
position: fixed;
1287+
right: 1rem;
1288+
bottom: 1rem;
1289+
z-index: 220;
1290+
max-width: min(24rem, calc(100vw - 2rem));
1291+
padding: 0.68rem 0.8rem;
1292+
border: 1px solid var(--line);
1293+
border-radius: var(--radius);
1294+
background: rgb(255 255 255 / 94%);
1295+
box-shadow: var(--shadow);
1296+
color: var(--ink);
1297+
font-size: 0.82rem;
1298+
font-weight: 760;
1299+
line-height: 1.4;
1300+
opacity: 0;
1301+
pointer-events: none;
1302+
transform: translateY(0.45rem);
1303+
transition:
1304+
opacity 160ms ease,
1305+
transform 160ms ease;
12811306
}
12821307

1283-
.annotation-tools .icon-button {
1284-
width: 100%;
1308+
.annotation-status.is-visible {
1309+
opacity: 1;
1310+
transform: translateY(0);
12851311
}
12861312

1287-
.annotation-status {
1288-
min-height: 2.4rem;
1289-
padding: 0.62rem;
1290-
border: 1px dashed var(--line);
1291-
border-radius: var(--radius-sm);
1292-
color: var(--muted);
1293-
font-size: 0.82rem;
1294-
line-height: 1.45;
1313+
.annotation-popover {
1314+
position: fixed;
1315+
top: 0;
1316+
left: 0;
1317+
z-index: 230;
1318+
display: inline-flex;
1319+
gap: 0.25rem;
1320+
align-items: center;
1321+
padding: 0.28rem;
1322+
border: 1px solid rgb(180 197 192 / 88%);
1323+
border-radius: 999px;
1324+
background: rgb(255 255 255 / 95%);
1325+
box-shadow: 0 12px 30px rgb(29 37 40 / 17%);
1326+
backdrop-filter: blur(16px);
1327+
transform: translate(-50%, calc(-100% - 0.55rem));
1328+
transform-origin: center bottom;
1329+
}
1330+
1331+
.annotation-popover.is-below {
1332+
transform: translate(-50%, 0.55rem);
1333+
transform-origin: center top;
1334+
}
1335+
1336+
.annotation-popover::after {
1337+
position: absolute;
1338+
bottom: -0.36rem;
1339+
left: 50%;
1340+
width: 0.66rem;
1341+
height: 0.66rem;
1342+
border-right: 1px solid rgb(180 197 192 / 88%);
1343+
border-bottom: 1px solid rgb(180 197 192 / 88%);
1344+
background: rgb(255 255 255 / 95%);
1345+
content: "";
1346+
transform: translateX(-50%) rotate(45deg);
1347+
}
1348+
1349+
.annotation-popover.is-below::after {
1350+
top: -0.36rem;
1351+
bottom: auto;
1352+
border: 0;
1353+
border-top: 1px solid rgb(180 197 192 / 88%);
1354+
border-left: 1px solid rgb(180 197 192 / 88%);
1355+
}
1356+
1357+
.annotation-popover .icon-button {
1358+
position: relative;
1359+
z-index: 1;
1360+
width: 2.35rem;
1361+
height: 2.35rem;
1362+
border-color: transparent;
1363+
border-radius: 999px;
1364+
background: transparent;
1365+
box-shadow: none;
1366+
}
1367+
1368+
.annotation-popover .icon-button:hover {
1369+
border-color: var(--line);
1370+
background: var(--accent-soft);
12951371
}
12961372

12971373
.annotation-list {

0 commit comments

Comments
 (0)