import { h, Component, ComponentChild, createRef } from 'preact'; import { asArray } from 'web-annotation-utils'; import type { BodyChoice, TextualBody, WebAnnotation } from 'web-annotation-utils'; import classes from './AnnotationBody.module.scss'; interface AnnotationBodyProps { body: WebAnnotation['body']; bodyValue: WebAnnotation['bodyValue']; editable?: boolean; onChange?: (newBody: WebAnnotation['body']) => void; onBlur?: () => void; inFocus?: boolean; } interface AnnotationBodyState {} export class AnnotationBody extends Component< AnnotationBodyProps, AnnotationBodyState > { editorElement = createRef(); componentDidMount() { if (this.props.inFocus) { this.editorElement.current?.focus({ preventScroll: true }); } } componentDidUpdate(previousProps: Readonly) { if (this.props.inFocus && !previousProps.inFocus) { this.editorElement.current?.focus(); } } render({ body, bodyValue, editable }: AnnotationBodyProps) { // An annotation either contains a `bodyValue` (simply a string), or an actual `body`. if (bodyValue !== undefined) return this.renderBodyValue(bodyValue); const result = this.renderBody(body); if (result === null && editable) { // For an empty but editable body, render an empty text field. return this.renderBodyValue(''); } else { return result; } } renderBodyValue(bodyValue: string) { // A bodyValue is defined as equivalent to a TextualBody containing this value. return this.renderTextualBody({ type: 'TextualBody', value: bodyValue, format: 'text/plain', }); } renderTextualBody(body: TextualBody) { // TODO use other available information: textDirection, format, …? return (
this.props.onChange?.({ ...body, value: (e.target as HTMLParagraphElement).textContent!, }) } onBlur={() => this.props.onBlur?.()} > {body.value}
); } renderBody(body: WebAnnotation['body']): ComponentChild { if (!body) { return null; } // A body can take many forms. Handle each as well as we can. // Firstly, it could be a string, identifying the body resource. if (typeof body === 'string') { // We assume the body is the URL of the body content. // TODO Handle the case where this string instead refers to a JSON-LD node (e.g. a SpecificResource or Choice). return this.renderIframe(body); } // There can be multiple bodies (according to the spec, each body is equally applicable to each target). if (Array.isArray(body)) { // We simply concatenate the bodies. Perhaps not the clearest/prettiest, but simple. return body.map((actualBody) => this.renderBody(actualBody)); } // TextualBody, a body consisting of a simple text value. if ('type' in body && body.type === 'TextualBody') { return this.renderTextualBody(body as TextualBody); } if ('type' in body && body.type === 'Choice') { const bodyOptions = (body as BodyChoice).items; if (bodyOptions.length === 0) return null; // The default option is listed first; take that. return this.renderBody(bodyOptions[0]); } if ('source' in body) { // The body is a Specific Resource. // TODO Try render exactly that part of the resource indicated by body.selector. return this.renderIframe(body.source); } // The body is an External Web Resource. Depending on its type, render an appropriate element. if ( asArray(body.format).every((item) => item.startsWith('image/')) || asArray(body.type).every((item) => item === 'Image') ) { return this.renderImage(body.id); } if ( asArray(body.format).every((item) => item.startsWith('audio/')) || asArray(body.type).every((item) => item === 'Sound') ) { return this.renderAudio(body.id); } if ( asArray(body.format).every((item) => item.startsWith('video/')) || asArray(body.type).every((item) => item === 'Video') ) { return this.renderAudio(body.id); } return this.renderIframe(body.id); } renderImage(bodyUrl: string) { return ; } renderAudio(bodyUrl: string) { return ( ); } renderVideo(bodyUrl: string) { return ; } renderIframe(bodyUrl: string) { return ( ); } }