UI: improved pager (#8730)

This commit is contained in:
Adorian Doran 2026-02-17 15:02:11 +02:00 committed by GitHub
commit 6aaf277b45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 223 additions and 73 deletions

View File

@ -145,6 +145,10 @@ button.tn-low-profile:hover {
font-size: calc(var(--icon-button-size) * var(--icon-button-icon-ratio));
}
:root .icon-action.disabled::before {
opacity: .5;
}
:root .icon-action:not(.global-menu-button):hover,
:root .icon-action:not(.global-menu-button).show,
:root .tn-tool-button:hover,

View File

@ -2190,8 +2190,9 @@
"percentage": "%"
},
"pagination": {
"page_title": "Page of {{startIndex}} - {{endIndex}}",
"total_notes": "{{count}} notes"
"total_notes": "{{count}} notes",
"prev_page": "Previous page",
"next_page": "Next page"
},
"collections": {
"rendering_error": "Unable to show content due to an error."

View File

@ -23,14 +23,3 @@ body.prefers-centered-content .note-list-widget:not(.full-height) {
.note-list-widget video {
height: 100%;
}
/* #region Pagination */
.note-list-pager {
font-size: 1rem;
span.current-page {
text-decoration: underline;
font-weight: bold;
}
}
/* #endregion */

View File

@ -0,0 +1,88 @@
:where(.note-list-pager) {
--note-list-pager-page-button-width: 40px;
--note-list-pager-page-button-gap: 3px;
--note-list-pager-ellipsis-width: 20px;
--note-list-pager-justify-content: flex-end;
--note-list-pager-current-page-button-background-color: var(--button-group-active-button-background);
--note-list-pager-current-page-button-text-color: var(--button-group-active-button-text-color);
}
.note-list-pager-container {
display: flex;
flex-direction: column;
width: 100%;
container: note-list-pager / inline-size;
}
.note-list-pager {
display: flex;
align-items: center;
font-size: .8rem;
align-self: var(--note-list-pager-justify-content);
.note-list-pager-nav-button {
--icon-button-icon-ratio: .75;
}
.note-list-pager-page-button-container {
display: flex;
align-items: baseline;
justify-content: space-around;
gap: var(--note-list-pager-page-button-gap);
&.note-list-pager-ellipsis-present {
/* Prevent the prev/next buttons from shifting when ellipses appear or disappear */
--_gap-max-width: calc((var(--note-list-pager-page-button-count) + 2) * var(--note-list-pager-page-button-gap));
min-width: calc(var(--note-list-pager-page-button-count) * var(--note-list-pager-page-button-width)
+ (var(--note-list-pager-ellipsis-width) * 2)
+ var(--_gap-max-width));
}
.note-list-pager-page-button {
min-width: var(--note-list-pager-page-button-width);
padding-inline: 0;
padding-block: 4px;
&.note-list-pager-page-button-current {
background: var(--note-list-pager-current-page-button-background-color);
color: var(--note-list-pager-current-page-button-text-color);
font-weight: bold;
opacity: unset;
}
}
.note-list-pager-ellipsis {
display: inline-block;
width: var(--note-list-pager-ellipsis-width);
text-align: center;
opacity: .5;
}
}
.note-list-pager-narrow-counter {
display: none;
min-width: 60px;
text-align: center;
white-space: nowrap;
}
.note-list-pager-total-count {
margin-inline-start: 8px;
opacity: .5;
white-space: nowrap;
}
@container note-list-pager (max-width: 550px) {
.note-list-pager-page-button-container,
.note-list-pager-total-count {
display: none;
}
.note-list-pager-narrow-counter {
display: block;
}
}
}

View File

@ -4,6 +4,10 @@ import FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { useNoteLabelInt } from "../react/hooks";
import { t } from "../../services/i18n";
import ActionButton from "../react/ActionButton";
import Button from "../react/Button";
import "./Pagination.css";
import clsx from "clsx";
interface PaginationContext {
page: number;
@ -17,46 +21,106 @@ interface PaginationContext {
export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit<PaginationContext, "pageNotes">) {
if (pageCount < 2) return;
let lastPrinted = false;
let children: ComponentChildren[] = [];
for (let i = 1; i <= pageCount; i++) {
if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(page - i) <= 2) {
lastPrinted = true;
const startIndex = (i - 1) * pageSize + 1;
const endIndex = Math.min(totalNotes, i * pageSize);
if (i !== page) {
children.push((
<a
href="javascript:"
title={t("pagination.page_title", { startIndex, endIndex })}
onClick={() => setPage(i)}
>
{i}
</a>
))
} else {
// Current page
children.push(<span className="current-page">{i}</span>)
}
children.push(<>{" "}&nbsp;{" "}</>);
} else if (lastPrinted) {
children.push(<>{"... "}&nbsp;{" "}</>);
lastPrinted = false;
}
}
return (
<div class="note-list-pager">
{children}
<div className="note-list-pager-container">
<div className="note-list-pager">
<ActionButton
icon="bx bx-chevron-left"
className="note-list-pager-nav-button"
disabled={(page === 1)}
text={t("pagination.prev_page")}
onClick={() => setPage(page - 1)}
/>
<span className="note-list-pager-total-count">({t("pagination.total_notes", { count: totalNotes })})</span>
<PageButtons page={page} setPage={setPage} pageCount={pageCount} />
<div className="note-list-pager-narrow-counter">
<strong>{page}</strong> / <strong>{pageCount}</strong>
</div>
<ActionButton
icon="bx bx-chevron-right"
className="note-list-pager-nav-button"
disabled={(page === pageCount)}
text={t("pagination.next_page")}
onClick={() => setPage(page + 1)}
/>
<div className="note-list-pager-total-count">
{t("pagination.total_notes", { count: totalNotes })}
</div>
</div>
</div>
)
}
interface PageButtonsProps {
page: number;
setPage: Dispatch<StateUpdater<number>>;
pageCount: number;
}
function PageButtons(props: PageButtonsProps) {
const maxButtonCount = 9;
const maxLeftRightSegmentLength = 2;
// The left-side segment
const leftLength = Math.min(props.pageCount, maxLeftRightSegmentLength);
const leftStart = 1;
// The middle segment
const middleMaxLength = maxButtonCount - maxLeftRightSegmentLength * 2;
const middleLength = Math.min(props.pageCount - leftLength, middleMaxLength);
let middleStart = props.page - Math.floor(middleLength / 2);
middleStart = Math.max(middleStart, leftLength + 1);
// The right-side segment
const rightLength = Math.min(props.pageCount - (middleLength + leftLength), maxLeftRightSegmentLength);
const rightStart = props.pageCount - rightLength + 1;
middleStart = Math.min(middleStart, rightStart - middleLength);
const totalButtonCount = leftLength + middleLength + rightLength;
const hasLeadingEllipsis = (middleStart - leftLength > 1);
const hasTrailingEllipsis = (rightStart - (middleStart + middleLength - 1) > 1);
return <div className={clsx("note-list-pager-page-button-container", {
"note-list-pager-ellipsis-present": (totalButtonCount === maxButtonCount)
})}
style={{"--note-list-pager-page-button-count": totalButtonCount}}>
{[
...createSegment(leftStart, leftLength, props.page, props.setPage, false),
...createSegment(middleStart, middleLength, props.page, props.setPage, hasLeadingEllipsis),
...createSegment(rightStart, rightLength, props.page, props.setPage, hasTrailingEllipsis),
]}
</div>;
}
function createSegment(start: number, length: number, currentPage: number, setPage: Dispatch<StateUpdater<number>>, prependEllipsis: boolean): ComponentChildren[] {
const children: ComponentChildren[] = [];
if (prependEllipsis) {
children.push(<span className="note-list-pager-ellipsis">...</span>);
}
for (let i = 0; i < length; i++) {
const pageNum = start + i;
const isCurrent = (pageNum === currentPage);
children.push((
<Button
text={pageNum.toString()}
kind="lowProfile"
className={clsx(
"note-list-pager-page-button",
{"note-list-pager-page-button-current": isCurrent}
)}
disabled={isCurrent}
onClick={() => setPage(pageNum)}
/>
));
}
return children;
}
export function usePagination(note: FNote, noteIds: string[]): PaginationContext {
const [ page, setPage ] = useState(1);
const [ pageNotes, setPageNotes ] = useState<FNote[]>();

View File

@ -100,10 +100,6 @@
overflow: auto;
}
.note-list-pager {
text-align: center;
}
/* #region List view */
@keyframes note-preview-show {

View File

@ -57,7 +57,7 @@ export default function BulkActionsDialog() {
className="bulk-actions-dialog"
size="xl"
title={t("bulk_actions.bulk_actions")}
footer={<Button text={t("bulk_actions.execute_bulk_actions")} primary />}
footer={<Button text={t("bulk_actions.execute_bulk_actions")} kind="primary" />}
show={shown}
onSubmit={async () => {
await server.post("bulk-action/execute", {

View File

@ -72,7 +72,7 @@ export default function DeleteNotesDialog() {
footer={<>
<Button text={t("delete_notes.cancel")}
onClick={() => setShown(false)} />
<Button text={t("delete_notes.ok")} primary
<Button text={t("delete_notes.ok")} kind="primary"
buttonRef={okButtonRef}
onClick={() => {
opts.callback?.({ proceed: true, deleteAllClones, eraseNotes });

View File

@ -58,7 +58,7 @@ export default function ExportDialog() {
setShown(false);
}}
onHidden={() => setShown(false)}
footer={<Button className="export-button" text={t("export.export")} primary />}
footer={<Button className="export-button" text={t("export.export")} kind="primary" />}
show={shown}
>

View File

@ -55,7 +55,7 @@ export default function ImportDialog() {
setShown(false);
setFiles(null);
}}
footer={<Button text={t("import.import")} primary disabled={!files} />}
footer={<Button text={t("import.import")} kind="primary" disabled={!files} />}
show={shown}
>
<FormGroup name="files" label={t("import.chooseImportFile")} description={

View File

@ -69,7 +69,7 @@ export default function PromptDialog() {
submitValue.current = null;
opts.current = undefined;
}}
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" kind="primary" />}
show={shown}
stackable
>

View File

@ -203,7 +203,7 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
}} />
&nbsp;
<Button
primary
kind="primary"
icon="bx bx-download"
text={t("revisions.download_button")}
onClick={() => {

View File

@ -35,7 +35,7 @@ export default function UploadAttachmentsDialog() {
className="upload-attachments-dialog"
size="lg"
title={t("upload_attachments.upload_attachments_to_note")}
footer={<Button text={t("upload_attachments.upload")} primary disabled={!files || isUploading} />}
footer={<Button text={t("upload_attachments.upload")} kind="primary" disabled={!files || isUploading} />}
onSubmit={async () => {
if (!files || !parentNoteId) {
return;

View File

@ -18,7 +18,7 @@ export interface ButtonProps {
keyboardShortcut?: string;
/** Called when the button is clicked. If not set, the button will submit the form (if any). */
onClick?: () => void;
primary?: boolean;
kind?: "primary" | "secondary" | "lowProfile";
disabled?: boolean;
size?: "normal" | "small" | "micro";
style?: CSSProperties;
@ -26,15 +26,23 @@ export interface ButtonProps {
title?: string;
}
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, kind, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
// Memoize classes array to prevent recreation
const classes = useMemo(() => {
const classList: string[] = ["btn"];
if (primary) {
classList.push("btn-primary");
} else {
classList.push("btn-secondary");
switch(kind) {
case "primary":
classList.push("btn-primary");
break;
case "lowProfile":
classList.push("tn-low-profile");
break;
default:
classList.push("btn-secondary");
break;
}
if (className) {
classList.push(className);
}
@ -44,7 +52,7 @@ const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortc
classList.push("btn-micro");
}
return classList.join(" ");
}, [primary, className, size]);
}, [kind, className, size]);
// Memoize keyboard shortcut rendering
const shortcutElements = useMemo(() => {

View File

@ -43,7 +43,7 @@ export default function FilePropertiesTab({ note, ntxId }: Pick<TabContext, "not
<Button
icon="bx bx-download"
text={t("file_properties.download")}
primary
kind="primary"
disabled={!canAccessProtectedNote}
onClick={() => downloadFileNote(note, parentComponent, ntxId)}
/>

View File

@ -43,7 +43,7 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
<Button
text={t("image_properties.download")}
icon="bx bx-download"
primary
kind="primary"
onClick={() => downloadFileNote(note, parentComponent, ntxId)}
/>

View File

@ -34,7 +34,7 @@ export default function ProtectedSession() {
<Button
text={t("protected_session.start_session_button")}
primary
kind="primary"
keyboardShortcut="Enter"
/>
</form>

View File

@ -107,7 +107,7 @@ function DisabledRender({ note }: TypeWidgetProps) {
text={t("render.disabled_button_enable")}
icon="bx bx-check-shield"
onClick={() => attributes.toggleDangerousAttribute(note, "relation", "renderNote", true)}
primary
kind="primary"
/>
</SetupForm>
);

View File

@ -74,7 +74,7 @@ function SetupWebView({note}: {note: FNote}) {
<Button
text={t("web_view_setup.create_button")}
primary
kind="primary"
keyboardShortcut="Enter"
/>
</SetupForm>
@ -96,7 +96,7 @@ function DisabledWebView({ note, url }: { note: FNote, url: string }) {
text={t("web_view_setup.disabled_button_enable")}
icon="bx bx-check-shield"
onClick={() => attributes.toggleDangerousAttribute(note, "label", "webViewSrc", true)}
primary
kind="primary"
/>
</SetupForm>
);

View File

@ -95,7 +95,7 @@ function ChangePassword() {
<Button
text={t("password.change_password")}
primary
kind="primary"
/>
</form>
</OptionsSection>

View File

@ -65,7 +65,7 @@ export function SyncConfiguration() {
</FormGroup>
<div style={{ display: "flex", justifyContent: "spaceBetween"}}>
<Button text={t("sync_2.save")} primary />
<Button text={t("sync_2.save")} kind="primary" />
<Button text={t("sync_2.help")} onClick={() => openInAppHelpFromUrl("cbkrhQjrkKrh")} />
</div>
</form>