M17–M19: #133–#137 (Kommentare inline, Statuszeile, Transklusion, Excalidraw, Checkbox-Fix) #138

Merged
fable-5 merged 10 commits from m17-m19-issues into main 2026-07-19 09:38:38 +02:00
7 changed files with 133 additions and 1 deletions
Showing only changes of commit c858f12592 - Show all commits

View File

@ -0,0 +1,22 @@
/**
* Approximate word count from plain text (issue #134). Splits on whitespace
* and keeps only tokens that contain a letter or digit, so markdown/HTML
* punctuation (`##`, `-`, `|`, `>`) never inflates the count.
*/
export function countWords(text: string): number {
if (!text) return 0;
return text
.trim()
.split(/\s+/)
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
}
/**
* Strip HTML to its visible text using the browser's parser (issue #134). Used
* by the public read view to derive a word count from the server-rendered HTML
* without loading the editor bundle.
*/
export function htmlToText(html: string): string {
if (!html) return '';
return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
}

View File

@ -32,9 +32,11 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { usePageActionsSlot } from '../layout/page-actions';
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiError, apiGet, apiGetText, apiPatch, apiPost } from '../lib/api';
import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard';
import { countWords } from '../lib/word-count';
import { PageActions } from './PageActions';
import { PageStatusBar } from './PageStatusBar';
import { recallPage, rememberPage } from '../offline/page-cache';
import { PluginBlockContext } from '../editor/plugin-block-context';
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
@ -360,6 +362,16 @@ export function PageEditorPage(): React.JSX.Element {
enabled: Boolean(pond.data),
});
// Word count for the read-mode status line (#134). Derived from the same
// Markdown export the history panel uses (shared query key), fetched only in
// reading mode so the editor session pays nothing.
const pageMarkdown = useQuery({
queryKey: ['page-markdown', page.data?.id],
queryFn: () => apiGetText(`/pages/${page.data!.id}/export/markdown`),
enabled: mode === 'view' && Boolean(page.data?.id),
});
const wordCount = useMemo(() => countWords(pageMarkdown.data ?? ''), [pageMarkdown.data]);
// Remember this page's metadata while online so it can be opened offline (#38).
useEffect(() => {
if (pond.data && page.data) {
@ -521,6 +533,11 @@ export function PageEditorPage(): React.JSX.Element {
onBlur={() => void saveTitle()}
/>
</div>
{/* Status line between the header and the article (#134): last update,
word count, reading time reading mode only. */}
{mode === 'view' && page.data && (
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
)}
<div className="editor-page__body">
<PageEditor
page={resolved}

View File

@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
/** Average silent reading speed; reading time = ceil(words / WPM). */
const WORDS_PER_MINUTE = 200;
/**
* A slim status line shown between the page header and the article body
* (issue #134): last update, word count, and estimated reading time. Rendered
* both in the authenticated read view and the anonymous public view, so it
* takes already-derived values as props rather than reaching into the editor.
*/
export function PageStatusBar({
updatedAt,
wordCount,
}: {
updatedAt: string;
wordCount: number;
}): React.JSX.Element {
const { t, i18n } = useTranslation('common');
const updated = useMemo(() => {
const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat(i18n.language, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
}, [updatedAt, i18n.language]);
const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
return (
<div className="page-statusbar" aria-label={t('statusbar.label')}>
{updated && (
<span className="page-statusbar__item">{t('statusbar.updated', { date: updated })}</span>
)}
<span className="page-statusbar__item">{t('statusbar.words', { count: wordCount })}</span>
{wordCount > 0 && (
<span className="page-statusbar__item">{t('statusbar.readingTime', { minutes })}</span>
)}
</div>
);
}

View File

@ -1,9 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ApiError, apiGet } from '../lib/api';
import { countWords, htmlToText } from '../lib/word-count';
import { NotFoundPage } from './NotFoundPage';
import { PageStatusBar } from './PageStatusBar';
interface PublicPageContent {
pondName: string;
@ -31,6 +34,14 @@ export function PublicPageView(): React.JSX.Element {
retry: false,
});
// Word count for the status line (#134), derived from the server-rendered
// HTML — no editor bundle needed. Kept before the early returns so the hook
// order stays stable.
const wordCount = useMemo(
() => countWords(htmlToText(query.data?.html ?? '')),
[query.data?.html],
);
if (query.error instanceof ApiError && query.error.status === 404) return <NotFoundPage />;
if (query.isLoading || !query.data) return <div aria-busy="true" />;
@ -40,6 +51,7 @@ export function PublicPageView(): React.JSX.Element {
<p className="public-page__badge">{t('readOnlyBadge')}</p>
<p className="public-page__pond">{page.pondName}</p>
<h1 className="public-page__title">{page.title}</h1>
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
{/* The HTML comes from the server's content cache (issue #24), derived
from the sanitized editor schema safe to render. */}
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />

View File

@ -979,6 +979,29 @@ button {
outline-offset: 2px;
}
/* Status line between page header and article (#134). Middot-separated,
muted; shared by the authenticated read view and the public view. */
.page-statusbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: calc(-1 * var(--space-2));
margin-bottom: var(--space-4);
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.page-statusbar__item {
display: inline-flex;
align-items: center;
}
.page-statusbar__item:not(:first-child)::before {
content: '·';
margin-right: var(--space-2);
color: var(--color-border);
}
/* The export entries flow inline with the other overflow-menu items. */
.editor-page__export {
display: contents;

View File

@ -1,4 +1,11 @@
{
"statusbar": {
"label": "Seiteninformationen",
"updated": "Aktualisiert {{date}}",
"words_one": "{{count}} Wort",
"words_other": "{{count}} Wörter",
"readingTime": "ca. {{minutes}} Min. Lesezeit"
},
"layout": {
"sidebar": {
"expand": "Seitenleiste einblenden",

View File

@ -1,4 +1,11 @@
{
"statusbar": {
"label": "Page information",
"updated": "Updated {{date}}",
"words_one": "{{count}} word",
"words_other": "{{count}} words",
"readingTime": "~{{minutes}} min read"
},
"layout": {
"sidebar": {
"expand": "Show sidebar",