Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions demo/App.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 65 additions & 23 deletions demo/App.tsx

Large diffs are not rendered by default.

39 changes: 27 additions & 12 deletions src/components/AnnotationsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,50 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { DocxSessionController } from '../session';
import type { EditResult } from 'docxodus/core';
import { useSessionAnnotations } from '../hooks/useSessionFeatures';
import type { CharSpan, EditResult } from 'docxodus/core';
import { useSelectionTarget, useSessionAnnotations } from '../hooks/useSessionFeatures';

export interface AnnotationsPanelProps { session: DocxSessionController; anchorId?: string; author?: string; onSelect?: (anchorId: string) => void }
export function AnnotationsPanel({ session, anchorId, author = 'Reviewer', onSelect }: AnnotationsPanelProps) {
export interface AnnotationsPanelProps {
session: DocxSessionController;
anchorId?: string;
/** Selected text inside `anchorId`. Omit or pass an empty span to annotate the whole block. */
span?: CharSpan | null;
author?: string;
/** Change this value to move keyboard focus to the label field. */
focusRequest?: number;
onSelect?: (anchorId: string) => void;
}
export function AnnotationsPanel({ session, anchorId, span, author = 'Reviewer', focusRequest, onSelect }: AnnotationsPanelProps) {
const annotations = useSessionAnnotations(session);
const target = useSelectionTarget(session, anchorId, span);
const [label, setLabel] = useState('');
const [color, setColor] = useState('#ffeb3b');
const [editing, setEditing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const input = useRef<HTMLInputElement>(null);
useEffect(() => { if (focusRequest) input.current?.focus(); }, [focusRequest]);
const apply = (operation: () => EditResult) => {
try { const result = operation(); if (!result.success) throw new Error(result.error?.message ?? 'Annotation change failed.'); setError(null); return true; }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); return false; }
};
const submit = () => {
if (editing) { if (apply(() => annotations.updateAnnotation(editing, { label, color }))) { setEditing(null); setLabel(''); } }
else if (anchorId) {
else if (target) {
const id = crypto.randomUUID();
if (apply(() => annotations.addAnnotation(anchorId, null, { id, labelId: label, label, color, author, created: new Date().toISOString(), bookmarkName: '' }))) setLabel('');
if (apply(() => annotations.addAnnotation(target.anchorId, target.span, { id, labelId: label, label, color, author, created: new Date().toISOString(), bookmarkName: '' }))) setLabel('');
}
};
return <section className="rdv-feature-panel" aria-label="Annotations"><div className="rdv-panel-heading"><span>MARK WHAT MATTERS</span><h3>Annotations</h3><p>Give key passages a label, a color, and a place in your workflow.</p></div>
{(error || annotations.error) && <p role="alert">{error ?? annotations.error?.message}</p>}
<ul>{annotations.annotations.map(annotation => <li key={annotation.id}><strong style={{ borderLeft: `4px solid ${annotation.color}`, paddingLeft: 8 }}>{annotation.label}</strong><p>{annotation.annotatedText}</p><div className="rdv-review-actions">
{onSelect && <button type="button" onClick={() => { const target = annotations.findByAnnotation(annotation.id)[0]; if (target) onSelect(target.id); }}>Show annotation</button>}
<button type="button" onClick={() => { setEditing(annotation.id); setLabel(annotation.label); setColor(annotation.color); }}>Edit label</button>
{anchorId && <button type="button" onClick={() => apply(() => annotations.moveAnnotation(annotation.id, anchorId, null))}>Move to selected block</button>}
{onSelect && <button type="button" onClick={() => { const found = annotations.findByAnnotation(annotation.id)[0]; if (found) onSelect(found.id); }}>Show annotation</button>}
<button type="button" onClick={() => { setEditing(annotation.id); setLabel(annotation.label); setColor(annotation.color); input.current?.focus(); }}>Edit label</button>
{target && <button type="button" onClick={() => apply(() => annotations.moveAnnotation(annotation.id, target.anchorId, target.span))}>{target.span ? 'Move to selection' : 'Move to selected block'}</button>}
<button type="button" onClick={() => apply(() => annotations.removeAnnotation(annotation.id))}>Remove annotation</button>
</div></li>)}</ul>
<form onSubmit={event => { event.preventDefault(); submit(); }}><label>Annotation label<input value={label} placeholder="e.g. Key decision" onChange={event => setLabel(event.target.value)} /></label><label>Highlight color<input type="color" value={color} onChange={event => setColor(event.target.value)} /></label><button className="rdv-primary-action" type="submit" disabled={!label || (!anchorId && !editing)}>{editing ? 'Save label' : 'Annotate selected block'}</button>{editing && <button type="button" onClick={() => { setEditing(null); setLabel(''); }}>Cancel</button>}</form>
{!anchorId && !editing && <p>Select a document block to add an annotation.</p>}
<form onSubmit={event => { event.preventDefault(); submit(); }}>
{target && !editing && <blockquote className="rdv-selection-target" aria-label={target.span ? 'Selected text' : 'Selected block'}><span>{target.span ? 'Selected text' : 'Selected block'}</span>{target.text || 'Empty paragraph'}</blockquote>}
<label>Annotation label<input ref={input} value={label} placeholder="e.g. Key decision" onChange={event => setLabel(event.target.value)} /></label><label>Highlight color<input type="color" value={color} onChange={event => setColor(event.target.value)} /></label><button className="rdv-primary-action" type="submit" disabled={!label || (!target && !editing)}>{editing ? 'Save label' : target?.span ? 'Annotate selection' : 'Annotate selected block'}</button>{editing && <button type="button" onClick={() => { setEditing(null); setLabel(''); }}>Cancel</button>}
</form>
{!target && !editing && <p>Select text or a document block to add an annotation.</p>}
</section>;
}
57 changes: 44 additions & 13 deletions src/components/CommentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,49 @@
import { useState } from 'react';
import type { EditResult } from 'docxodus/core';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CharSpan, CommentListEntry, EditResult } from 'docxodus/core';
import type { DocxSessionController } from '../session';
import { useDocumentComments } from '../hooks/useSessionFeatures';
import { useDocumentComments, useSelectionTarget } from '../hooks/useSessionFeatures';
import { Icon } from './Icon';

/** Host-supplied location details, e.g. read from the rendered comment highlight. */
export interface CommentContext { text?: string; order?: number }

export interface CommentsPanelProps {
session: DocxSessionController;
anchorId?: string;
/** Selected text inside `anchorId`. Omit or pass an empty span to comment on the whole block. */
span?: CharSpan | null;
revisionId?: string;
author?: string;
/** Change this value to move keyboard focus to the comment field. */
focusRequest?: number;
/** Quote the commented text and order threads by document position. */
describeComment?: (comment: CommentListEntry) => CommentContext | undefined;
onSelect?: (anchorId: string) => void;
}

export function CommentsPanel({ session, anchorId, revisionId, author = 'Reviewer', onSelect }: CommentsPanelProps) {
/** Thread roots (in document order when known), each followed by its replies. */
function threads(comments: CommentListEntry[], describe?: CommentsPanelProps['describeComment']) {
const byAnchor = new Map(comments.map(comment => [comment.anchorId, comment]));
const root = (comment: CommentListEntry) => {
const seen = new Set<string>();
while (comment.parentAnchorId && byAnchor.has(comment.parentAnchorId) && !seen.has(comment.anchorId)) { seen.add(comment.anchorId); comment = byAnchor.get(comment.parentAnchorId)!; }
return comment;
};
const roots = comments.filter(comment => root(comment) === comment).map((comment, index) => ({ comment, index, context: describe?.(comment) }));
roots.sort((a, b) => (a.context?.order ?? Infinity) - (b.context?.order ?? Infinity) || a.index - b.index);
return roots.flatMap(({ comment, context }) => [{ comment, context }, ...comments.filter(reply => reply !== comment && root(reply) === comment).map(reply => ({ comment: reply, context: undefined }))]);
}

export function CommentsPanel({ session, anchorId, span, revisionId, author = 'Reviewer', focusRequest, describeComment, onSelect }: CommentsPanelProps) {
const comments = useDocumentComments(session);
const target = useSelectionTarget(session, anchorId, span);
const [text, setText] = useState('');
const [replyTo, setReplyTo] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const field = useRef<HTMLTextAreaElement>(null);
useEffect(() => { if (focusRequest) field.current?.focus(); }, [focusRequest]);
const ordered = useMemo(() => threads(comments.comments, describeComment), [comments.comments, describeComment]);
const apply = (operation: () => EditResult) => {
try {
const result = operation();
Expand All @@ -32,31 +58,36 @@ export function CommentsPanel({ session, anchorId, revisionId, author = 'Reviewe
? () => comments.updateComment(editing, text)
: replyTo ? () => comments.addCommentReply(replyTo, author, text)
: revisionId ? () => comments.addCommentToRevision(revisionId, author, text)
: anchorId ? () => comments.addComment(anchorId, null, author, text) : null;
: target ? () => comments.addComment(target.anchorId, target.span, author, text) : null;
if (operation && apply(operation)) { setText(''); setReplyTo(null); setEditing(null); }
};
const compose = (next: { replyTo?: string | null; editing?: string | null; text?: string }) => {
setReplyTo(next.replyTo ?? null); setEditing(next.editing ?? null); setText(next.text ?? ''); field.current?.focus();
};
return <section className="rdv-feature-panel" aria-label="Comments">
<div className="rdv-panel-heading"><span>KEEP THE CONVERSATION CLOSE</span><h3>Comments</h3><p>Thoughts, questions, and decisions. Right where they belong.</p></div>
{(error || comments.error) && <p role="alert">{error ?? comments.error?.message}</p>}
{comments.comments.length === 0 && <div className="rdv-panel-empty"><Icon name="comment" size={28} /><p>No comments yet.</p><small>Select a paragraph to start the conversation.</small></div>}
{comments.comments.length === 0 && <div className="rdv-panel-empty"><Icon name="comment" size={28} /><p>No comments yet.</p><small>Select text or a paragraph to start the conversation.</small></div>}
<ol className="rdv-comment-list">
{comments.comments.map(comment => <li key={comment.anchorId} className={comment.parentAnchorId ? 'rdv-comment-reply' : ''}>
{ordered.map(({ comment, context }) => <li key={comment.anchorId} className={comment.parentAnchorId ? 'rdv-comment-reply' : ''}>
<div className="rdv-comment-heading"><span className="rdv-avatar">{(comment.author || 'R').slice(0, 1).toUpperCase()}</span><strong>{comment.author}</strong>{comment.resolved && <span className="rdv-status-pill">Resolved</span>}</div>
{context?.text && <blockquote className="rdv-comment-context">{context.text}</blockquote>}
<p>{comment.text}</p>
<div className="rdv-review-actions">
{onSelect && <button type="button" onClick={() => onSelect(comment.anchorId)}>Show comment</button>}
<button type="button" onClick={() => { setReplyTo(comment.anchorId); setEditing(null); setText(''); }}>Reply</button>
<button type="button" onClick={() => { setEditing(comment.anchorId); setReplyTo(null); setText(comment.text); }}>Edit</button>
<button type="button" onClick={() => compose({ replyTo: comment.anchorId })}>Reply</button>
<button type="button" onClick={() => compose({ editing: comment.anchorId, text: comment.text })}>Edit</button>
{!comment.parentAnchorId && <button type="button" onClick={() => apply(() => comments.setCommentResolved(comment.anchorId, !comment.resolved))}>{comment.resolved ? 'Reopen' : 'Resolve'}</button>}
<button type="button" onClick={() => apply(() => comments.removeComment(comment.anchorId))}>Delete</button>
</div>
</li>)}
</ol>
<form onSubmit={event => { event.preventDefault(); submit(); }}>
<label>{editing ? 'Edit comment' : replyTo ? 'Reply' : 'New comment'}<textarea value={text} onChange={event => setText(event.target.value)} /></label>
{!anchorId && !revisionId && !replyTo && !editing && <p>Select a document block or revision to add a comment.</p>}
<button className="rdv-primary-action" type="submit" disabled={!text.trim() || (!anchorId && !revisionId && !replyTo && !editing)}>{editing ? 'Save comment' : replyTo ? 'Post reply' : 'Add comment'}<Icon name="arrow" size={15} /></button>
{(replyTo || editing) && <button type="button" onClick={() => { setReplyTo(null); setEditing(null); setText(''); }}>Cancel</button>}
{target && !revisionId && !replyTo && !editing && <blockquote className="rdv-selection-target" aria-label={target.span ? 'Selected text' : 'Selected block'}><span>{target.span ? 'Selected text' : 'Selected block'}</span>{target.text || 'Empty paragraph'}</blockquote>}
<label>{editing ? 'Edit comment' : replyTo ? 'Reply' : 'New comment'}<textarea ref={field} value={text} onChange={event => setText(event.target.value)} /></label>
{!target && !revisionId && !replyTo && !editing && <p>Select text, a document block or a revision to add a comment.</p>}
<button className="rdv-primary-action" type="submit" disabled={!text.trim() || (!target && !revisionId && !replyTo && !editing)}>{editing ? 'Save comment' : replyTo ? 'Post reply' : target?.span && !revisionId ? 'Comment on selection' : 'Add comment'}<Icon name="arrow" size={15} /></button>
{(replyTo || editing) && <button type="button" onClick={() => compose({})}>Cancel</button>}
</form>
</section>;
}
10 changes: 7 additions & 3 deletions src/components/PaginatedDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ export function PaginatedDocument({ html, canvasEditor, canvasOwner, liveBlocks,
wrapper.inert = true;
shadow.append(wrapper);
const selectionStyles = document.createElement('style');
selectionStyles.textContent = '[data-rdv-selected="true"] { outline: 1.5px solid var(--rdv-selection-color, #93aa79); outline-offset: 5px; border-radius: 1px; }';
// Annotation highlights are split at every run boundary; horizontal padding on each
// fragment would open visible gaps inside words and before punctuation.
selectionStyles.textContent = '[data-rdv-selected="true"] { outline: 1.5px solid var(--rdv-selection-color, #93aa79); outline-offset: 5px; border-radius: 1px; } .annot-highlight { padding-inline: 0; }';
wrapper.append(selectionStyles);
for (const stylesheet of wrapper.querySelectorAll('style')) {
if (stylesheet.sheet) adaptRootSelectors(stylesheet.sheet.cssRules);
Expand All @@ -254,8 +256,10 @@ export function PaginatedDocument({ html, canvasEditor, canvasOwner, liveBlocks,
}
};
documentBody.addEventListener('click', onClick);
const onSelection = () => {
if (canvasEditor) return;
const onSelection = (event: Event) => {
// The canvas editor reports selections in editable text itself. Read-only
// blocks (generated, annotated or unsupported content) still select here.
if (canvasEditor && event.target instanceof Element && event.target.closest('[data-rdv-editable="true"]')) return;
const selection = shadowSelection(documentBody);
if (callbacksRef.current.onTextSelectionChange && selection && !selection.isCollapsed) {
callbacksRef.current.onTextSelectionChange(readTextSelection(documentBody, activeLayout.current?.documentVersion));
Expand Down
Loading
Loading