626 lines
17 KiB
TypeScript
626 lines
17 KiB
TypeScript
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
|
|
|
import type {
|
|
OneNotePageDto,
|
|
OneNoteResourcePayloadDto,
|
|
} from '../onenote/model/dto.js';
|
|
import type {
|
|
OneNoteColor,
|
|
OneNoteContentBlock,
|
|
OneNoteImageBlock,
|
|
OneNoteInkBlock,
|
|
OneNoteInkStroke,
|
|
OneNoteLayout,
|
|
OneNoteTextRun,
|
|
OneNoteTextStyle,
|
|
} from '../onenote/one/content-model.js';
|
|
|
|
interface PageReaderProps {
|
|
page?: OneNotePageDto;
|
|
loading: boolean;
|
|
error?: string;
|
|
loadResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>;
|
|
downloadResource?(resourceId: string, filename?: string): Promise<void>;
|
|
}
|
|
|
|
function formatDate(value: string | undefined): string | null {
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(date);
|
|
}
|
|
|
|
export function PageReader({
|
|
page,
|
|
loading,
|
|
error,
|
|
loadResource,
|
|
downloadResource,
|
|
}: PageReaderProps) {
|
|
if (loading) {
|
|
return (
|
|
<section className="page-reader page-reader--empty" aria-live="polite">
|
|
<p>Loading structured page content…</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<section className="page-reader page-reader--error" role="alert">
|
|
<p className="eyebrow">Page unavailable</p>
|
|
<h2>Could not read this page</h2>
|
|
<p>{error}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (!page) {
|
|
return (
|
|
<section className="page-reader page-reader--empty">
|
|
<p>Select a readable page to inspect its content.</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const createdAt = formatDate(page.createdAt);
|
|
const updatedAt = formatDate(page.updatedAt);
|
|
|
|
return (
|
|
<article className="page-reader" aria-labelledby="selected-page-title">
|
|
<header className="page-reader__header">
|
|
<p className="eyebrow">Selected page</p>
|
|
<h2 id="selected-page-title">{page.title || 'Untitled page'}</h2>
|
|
{createdAt || updatedAt ? (
|
|
<dl className="page-metadata">
|
|
{createdAt ? (
|
|
<>
|
|
<dt>Created</dt>
|
|
<dd>{createdAt}</dd>
|
|
</>
|
|
) : null}
|
|
{updatedAt ? (
|
|
<>
|
|
<dt>Modified</dt>
|
|
<dd>{updatedAt}</dd>
|
|
</>
|
|
) : null}
|
|
</dl>
|
|
) : null}
|
|
</header>
|
|
|
|
<div className="page-content">
|
|
{page.blocks.length === 0 && page.text.length === 0 ? (
|
|
<p className="empty-message">No readable content was extracted.</p>
|
|
) : page.blocks.length === 0 ? (
|
|
<p className="extracted-text">{page.text}</p>
|
|
) : (
|
|
<ContentBlocks
|
|
blocks={page.blocks}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function ContentBlocks({
|
|
blocks,
|
|
loadResource,
|
|
downloadResource,
|
|
}: {
|
|
blocks: readonly OneNoteContentBlock[];
|
|
loadResource?: PageReaderProps['loadResource'];
|
|
downloadResource?: PageReaderProps['downloadResource'];
|
|
}) {
|
|
return blocks.map((block) => (
|
|
<ContentBlockView
|
|
key={block.id}
|
|
block={block}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
));
|
|
}
|
|
|
|
function ContentBlockView({
|
|
block,
|
|
loadResource,
|
|
downloadResource,
|
|
}: {
|
|
block: OneNoteContentBlock;
|
|
loadResource?: PageReaderProps['loadResource'];
|
|
downloadResource?: PageReaderProps['downloadResource'];
|
|
}) {
|
|
switch (block.kind) {
|
|
case 'text':
|
|
return (
|
|
<p
|
|
className="onenote-text"
|
|
dir={block.rightToLeft ? 'rtl' : undefined}
|
|
style={{
|
|
...layoutStyle(block.layout),
|
|
...paragraphStyle(block),
|
|
}}
|
|
>
|
|
{block.runs.map((run, index) => (
|
|
<TextRunView key={`${run.start}-${run.end}-${index}`} run={run} />
|
|
))}
|
|
</p>
|
|
);
|
|
case 'outline':
|
|
return (
|
|
<section className="onenote-outline" style={layoutStyle(block.layout)}>
|
|
<ContentBlocks
|
|
blocks={block.children}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
</section>
|
|
);
|
|
case 'outline-group':
|
|
return (
|
|
<div className="onenote-outline-group">
|
|
<ContentBlocks
|
|
blocks={block.children}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'outline-element':
|
|
return (
|
|
<div
|
|
className="onenote-outline-element"
|
|
style={{
|
|
marginInlineStart: `${Math.min(block.childLevel ?? 0, 12) * 1.1}rem`,
|
|
}}
|
|
>
|
|
<ContentBlocks
|
|
blocks={block.contents}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
<ContentBlocks
|
|
blocks={block.children}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'table':
|
|
return (
|
|
<div className="onenote-table-scroll" style={layoutStyle(block.layout)}>
|
|
<table
|
|
className={
|
|
block.bordersVisible
|
|
? 'onenote-table'
|
|
: 'onenote-table onenote-table--borderless'
|
|
}
|
|
>
|
|
{block.columnWidths.length > 0 ? (
|
|
<colgroup>
|
|
{block.columnWidths.map((width, index) => (
|
|
<col key={index} style={{ width: halfInches(width) }} />
|
|
))}
|
|
</colgroup>
|
|
) : null}
|
|
<tbody>
|
|
{block.rows.map((row) => (
|
|
<tr key={row.id}>
|
|
{row.cells.map((cell) => (
|
|
<td
|
|
key={cell.id}
|
|
style={{
|
|
backgroundColor: colorCss(cell.backgroundColor),
|
|
maxWidth: halfInches(cell.maxWidth),
|
|
}}
|
|
>
|
|
<ContentBlocks
|
|
blocks={cell.blocks}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
case 'image':
|
|
return (
|
|
<ImageView
|
|
image={block}
|
|
loadResource={loadResource}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
);
|
|
case 'attachment':
|
|
return (
|
|
<AttachmentView
|
|
resourceId={block.resourceId}
|
|
filename={block.filename}
|
|
size={block.size}
|
|
downloadResource={downloadResource}
|
|
/>
|
|
);
|
|
case 'ink':
|
|
return <InkView ink={block} />;
|
|
case 'unsupported':
|
|
return (
|
|
<aside className="unsupported-block">
|
|
<strong>Unsupported object 0x{block.sourceJcid.toString(16)}</strong>
|
|
<span>{block.description}</span>
|
|
</aside>
|
|
);
|
|
}
|
|
}
|
|
|
|
function TextRunView({ run }: { run: OneNoteTextRun }) {
|
|
if (run.style.hidden) return null;
|
|
const content = textWithBreaks(run.text);
|
|
const style = textStyle(run.style);
|
|
return run.href ? (
|
|
<a
|
|
className="onenote-link"
|
|
href={run.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={run.href}
|
|
style={style}
|
|
>
|
|
{content}
|
|
</a>
|
|
) : (
|
|
<span style={style}>{content}</span>
|
|
);
|
|
}
|
|
|
|
function textWithBreaks(text: string): ReactNode[] {
|
|
return text
|
|
.replaceAll('\u000b', '\n')
|
|
.split('\n')
|
|
.flatMap((part, index, values) =>
|
|
index + 1 < values.length
|
|
? [
|
|
<span key={`${index}-text`}>{part}</span>,
|
|
<br key={`${index}-br`} />,
|
|
]
|
|
: [<span key={`${index}-text`}>{part}</span>]
|
|
);
|
|
}
|
|
|
|
function ImageView({
|
|
image,
|
|
loadResource,
|
|
downloadResource,
|
|
}: {
|
|
image: OneNoteImageBlock;
|
|
loadResource?: PageReaderProps['loadResource'];
|
|
downloadResource?: PageReaderProps['downloadResource'];
|
|
}) {
|
|
const [state, setState] = useState<
|
|
| { phase: 'loading' }
|
|
| { phase: 'ready'; resourceId: string; url: string }
|
|
| { phase: 'unavailable'; resourceId: string; message: string }
|
|
>({ phase: 'loading' });
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
let objectUrl: string | undefined;
|
|
if (!image.resourceId || !loadResource) {
|
|
return;
|
|
}
|
|
const resourceId = image.resourceId;
|
|
void loadResource(image.resourceId)
|
|
.then((resource) => {
|
|
if (
|
|
disposed ||
|
|
!resource.browserRenderable ||
|
|
(resource.mediaType !== 'image/png' &&
|
|
resource.mediaType !== 'image/jpeg')
|
|
) {
|
|
if (!disposed) {
|
|
setState({
|
|
phase: 'unavailable',
|
|
resourceId,
|
|
message:
|
|
'This image format can be downloaded but not displayed safely.',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
objectUrl = URL.createObjectURL(
|
|
new Blob([resource.bytes], { type: resource.mediaType })
|
|
);
|
|
setState({ phase: 'ready', resourceId, url: objectUrl });
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!disposed) {
|
|
setState({
|
|
phase: 'unavailable',
|
|
resourceId,
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: 'The image could not be loaded.',
|
|
});
|
|
}
|
|
});
|
|
return () => {
|
|
disposed = true;
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
};
|
|
}, [image.resourceId, loadResource]);
|
|
|
|
const visibleState =
|
|
!image.resourceId || !loadResource
|
|
? {
|
|
phase: 'unavailable' as const,
|
|
resourceId: image.resourceId ?? '',
|
|
message: 'The image payload is not available.',
|
|
}
|
|
: state.phase !== 'loading' && state.resourceId === image.resourceId
|
|
? state
|
|
: { phase: 'loading' as const };
|
|
|
|
const fallback =
|
|
visibleState.phase === 'unavailable'
|
|
? visibleState.message
|
|
: 'Loading embedded image…';
|
|
const visual =
|
|
visibleState.phase === 'ready' ? (
|
|
<img
|
|
src={visibleState.url}
|
|
alt={image.altText ?? image.filename ?? ''}
|
|
style={{
|
|
width: halfInches(image.pictureWidth),
|
|
height: halfInches(image.pictureHeight),
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="image-placeholder" role="img" aria-label={image.altText}>
|
|
{fallback}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<figure className="onenote-image" style={layoutStyle(image.layout)}>
|
|
{image.href && visibleState.phase === 'ready' ? (
|
|
<a
|
|
href={image.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={image.href}
|
|
>
|
|
{visual}
|
|
</a>
|
|
) : (
|
|
visual
|
|
)}
|
|
{image.altText || image.filename ? (
|
|
<figcaption>{image.altText ?? image.filename}</figcaption>
|
|
) : null}
|
|
{visibleState.phase === 'unavailable' &&
|
|
image.resourceId &&
|
|
downloadResource ? (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
void downloadResource(image.resourceId!, image.filename)
|
|
}
|
|
>
|
|
Download original image
|
|
</button>
|
|
) : null}
|
|
</figure>
|
|
);
|
|
}
|
|
|
|
function InkView({ ink }: { ink: OneNoteInkBlock }) {
|
|
const box = ink.boundingBox ?? boundingBoxFromInk(ink.strokes);
|
|
const padding = Math.max(
|
|
140,
|
|
...ink.strokes.map((stroke) => stroke.width ?? 0)
|
|
);
|
|
const viewBox = box
|
|
? `${box.x - padding / 2} ${box.y - padding / 2} ${Math.max(box.width + padding, 1)} ${Math.max(box.height + padding, 1)}`
|
|
: '0 0 1 1';
|
|
const width = box ? Math.max((box.width + padding) / (2540 / 96), 1) : 1;
|
|
const height = box ? Math.max((box.height + padding) / (2540 / 96), 1) : 1;
|
|
|
|
return (
|
|
<div className="onenote-ink" style={layoutStyle(ink.layout)}>
|
|
{ink.strokes.length > 0 ? (
|
|
<svg
|
|
viewBox={viewBox}
|
|
width={width}
|
|
height={height}
|
|
role="img"
|
|
aria-label={`${ink.strokes.length} handwritten or drawn stroke${ink.strokes.length === 1 ? '' : 's'}`}
|
|
>
|
|
{ink.strokes.map((stroke, index) => (
|
|
<path
|
|
key={index}
|
|
d={inkPath(stroke)}
|
|
fill="none"
|
|
stroke={inkColor(stroke.color)}
|
|
strokeWidth={Math.max(stroke.width ?? 140, 1)}
|
|
strokeOpacity={
|
|
stroke.transparency === undefined
|
|
? undefined
|
|
: Math.max(0, Math.min(1, (255 - stroke.transparency) / 256))
|
|
}
|
|
strokeLinecap={stroke.penTip === 0 ? 'round' : 'square'}
|
|
strokeLinejoin={stroke.penTip === 0 ? 'round' : 'bevel'}
|
|
/>
|
|
))}
|
|
</svg>
|
|
) : null}
|
|
{ink.children.map((child) => (
|
|
<InkView key={child.id} ink={child} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function inkPath(stroke: OneNoteInkStroke): string {
|
|
const [first, ...points] = stroke.points;
|
|
if (!first) return '';
|
|
if (points.length === 0) return `M ${first.x} ${first.y} l 0 0`;
|
|
return `M ${first.x} ${first.y} L ${points
|
|
.map((point) => `${point.x} ${point.y}`)
|
|
.join(' ')}`;
|
|
}
|
|
|
|
function boundingBoxFromInk(
|
|
strokes: readonly OneNoteInkStroke[]
|
|
): { x: number; y: number; width: number; height: number } | undefined {
|
|
let xMin = Infinity;
|
|
let yMin = Infinity;
|
|
let xMax = -Infinity;
|
|
let yMax = -Infinity;
|
|
for (const stroke of strokes) {
|
|
for (const point of stroke.points) {
|
|
xMin = Math.min(xMin, point.x);
|
|
yMin = Math.min(yMin, point.y);
|
|
xMax = Math.max(xMax, point.x);
|
|
yMax = Math.max(yMax, point.y);
|
|
}
|
|
}
|
|
return Number.isFinite(xMin)
|
|
? { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin }
|
|
: undefined;
|
|
}
|
|
|
|
function inkColor(value: number | undefined): string {
|
|
if (value === undefined) return 'currentColor';
|
|
return `rgb(${value & 0xff}, ${(value >>> 8) & 0xff}, ${(value >>> 16) & 0xff})`;
|
|
}
|
|
|
|
function AttachmentView({
|
|
resourceId,
|
|
filename,
|
|
size,
|
|
downloadResource,
|
|
}: {
|
|
resourceId?: string;
|
|
filename: string;
|
|
size?: number;
|
|
downloadResource?: PageReaderProps['downloadResource'];
|
|
}) {
|
|
const [status, setStatus] = useState<string>();
|
|
const download = async () => {
|
|
if (!resourceId || !downloadResource) return;
|
|
setStatus('Preparing local download…');
|
|
try {
|
|
await downloadResource(resourceId, filename);
|
|
setStatus(undefined);
|
|
} catch (error) {
|
|
setStatus(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'The attachment could not be downloaded.'
|
|
);
|
|
}
|
|
};
|
|
return (
|
|
<aside className="attachment-card">
|
|
<div>
|
|
<strong>{filename}</strong>
|
|
{size === undefined ? null : <span>{formatBytes(size)}</span>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={!resourceId || !downloadResource}
|
|
onClick={() => void download()}
|
|
>
|
|
Download attachment
|
|
</button>
|
|
{status ? <small role="status">{status}</small> : null}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function paragraphStyle(
|
|
block: Extract<OneNoteContentBlock, { kind: 'text' }>
|
|
): CSSProperties {
|
|
return {
|
|
textAlign:
|
|
block.paragraphAlignment === 'unknown'
|
|
? undefined
|
|
: block.paragraphAlignment,
|
|
marginTop: halfInches(block.paragraphSpaceBefore),
|
|
marginBottom: halfInches(block.paragraphSpaceAfter),
|
|
lineHeight:
|
|
block.paragraphLineSpacingExact === undefined
|
|
? undefined
|
|
: halfInches(block.paragraphLineSpacingExact),
|
|
};
|
|
}
|
|
|
|
function textStyle(style: OneNoteTextStyle): CSSProperties {
|
|
const decorations = [
|
|
style.underline ? 'underline' : undefined,
|
|
style.strikethrough ? 'line-through' : undefined,
|
|
].filter(Boolean);
|
|
return {
|
|
fontFamily: style.font,
|
|
fontSize:
|
|
style.fontSize === undefined
|
|
? undefined
|
|
: `${Math.min(Math.max(style.fontSize / 2, 6), 144)}pt`,
|
|
fontWeight: style.bold ? 700 : undefined,
|
|
fontStyle: style.italic ? 'italic' : undefined,
|
|
textDecoration: decorations.length > 0 ? decorations.join(' ') : undefined,
|
|
verticalAlign: style.superscript
|
|
? 'super'
|
|
: style.subscript
|
|
? 'sub'
|
|
: undefined,
|
|
color: colorCss(style.fontColor),
|
|
backgroundColor: colorCss(style.highlight),
|
|
};
|
|
}
|
|
|
|
function colorCss(color: OneNoteColor | undefined): string | undefined {
|
|
if (!color || color.kind === 'auto') return undefined;
|
|
const red = color.red ?? 0;
|
|
const green = color.green ?? 0;
|
|
const blue = color.blue ?? 0;
|
|
return color.kind === 'rgba'
|
|
? `rgba(${red}, ${green}, ${blue}, ${(color.alpha ?? 255) / 255})`
|
|
: `rgb(${red}, ${green}, ${blue})`;
|
|
}
|
|
|
|
function layoutStyle(layout: OneNoteLayout): CSSProperties {
|
|
return {
|
|
maxWidth: halfInches(layout.maxWidth),
|
|
maxHeight: halfInches(layout.maxHeight),
|
|
marginInlineStart: halfInches(layout.offsetHorizontal, -1_000, 10_000),
|
|
marginTop: halfInches(layout.offsetVertical, -1_000, 10_000),
|
|
};
|
|
}
|
|
|
|
function halfInches(
|
|
value: number | undefined,
|
|
minimum = 0,
|
|
maximum = 10_000
|
|
): string | undefined {
|
|
if (value === undefined || !Number.isFinite(value)) return undefined;
|
|
return `${Math.min(Math.max(value * 48, minimum), maximum)}px`;
|
|
}
|
|
|
|
function formatBytes(size: number): string {
|
|
if (size < 1024) return `${size} B`;
|
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
|
|
return `${(size / (1024 * 1024)).toFixed(1)} MiB`;
|
|
}
|