Browser extension that demonstrates the Web Annotation Discovery mechanism: subscribe to people’s annotation collections/‘feeds’, to see their notes on the web; and create & publish annotations yourself.

web-annotation-discovery-we.../src/content_script/ AnnotationBody.tsx
165 lines
4.8 KiB

  1. import { h, Component, ComponentChild, createRef } from 'preact';
  2. import { asArray } from 'web-annotation-utils';
  3. import type { BodyChoice, TextualBody, WebAnnotation } from 'web-annotation-utils';
  4. import classes from './AnnotationBody.module.scss';
  5. interface AnnotationBodyProps {
  6. body: WebAnnotation['body'];
  7. bodyValue: WebAnnotation['bodyValue'];
  8. editable?: boolean;
  9. onChange?: (newBody: WebAnnotation['body']) => void;
  10. onBlur?: () => void;
  11. inFocus?: boolean;
  12. }
  13. interface AnnotationBodyState {}
  14. export class AnnotationBody extends Component<
  15. AnnotationBodyProps,
  16. AnnotationBodyState
  17. > {
  18. editorElement = createRef<HTMLDivElement>();
  19. componentDidMount() {
  20. if (this.props.inFocus) {
  21. this.editorElement.current?.focus({ preventScroll: true });
  22. }
  23. }
  24. componentDidUpdate(previousProps: Readonly<AnnotationBodyProps>) {
  25. if (this.props.inFocus && !previousProps.inFocus) {
  26. this.editorElement.current?.focus();
  27. }
  28. }
  29. render({ body, bodyValue, editable }: AnnotationBodyProps) {
  30. // An annotation either contains a `bodyValue` (simply a string), or an actual `body`.
  31. if (bodyValue !== undefined) return this.renderBodyValue(bodyValue);
  32. const result = this.renderBody(body);
  33. if (result === null && editable) {
  34. // For an empty but editable body, render an empty text field.
  35. return this.renderBodyValue('');
  36. } else {
  37. return result;
  38. }
  39. }
  40. renderBodyValue(bodyValue: string) {
  41. // A bodyValue is defined as equivalent to a TextualBody containing this value.
  42. return this.renderTextualBody({
  43. type: 'TextualBody',
  44. value: bodyValue,
  45. format: 'text/plain',
  46. });
  47. }
  48. renderTextualBody(body: TextualBody) {
  49. // TODO use other available information: textDirection, format, …?
  50. return (
  51. <div
  52. ref={this.editorElement}
  53. class={classes.annotationBodyText}
  54. contentEditable={this.props.editable}
  55. spellcheck={false}
  56. onInput={(e) =>
  57. this.props.onChange?.({
  58. ...body,
  59. value: (e.target as HTMLParagraphElement).textContent!,
  60. })
  61. }
  62. onBlur={() => this.props.onBlur?.()}
  63. >
  64. {body.value}
  65. </div>
  66. );
  67. }
  68. renderBody(body: WebAnnotation['body']): ComponentChild {
  69. if (!body) {
  70. return null;
  71. }
  72. // A body can take many forms. Handle each as well as we can.
  73. // Firstly, it could be a string, identifying the body resource.
  74. if (typeof body === 'string') {
  75. // We assume the body is the URL of the body content.
  76. // TODO Handle the case where this string instead refers to a JSON-LD node (e.g. a SpecificResource or Choice).
  77. return this.renderIframe(body);
  78. }
  79. // There can be multiple bodies (according to the spec, each body is equally applicable to each target).
  80. if (Array.isArray(body)) {
  81. // We simply concatenate the bodies. Perhaps not the clearest/prettiest, but simple.
  82. return body.map((actualBody) => this.renderBody(actualBody));
  83. }
  84. // TextualBody, a body consisting of a simple text value.
  85. if ('type' in body && body.type === 'TextualBody') {
  86. return this.renderTextualBody(body as TextualBody);
  87. }
  88. if ('type' in body && body.type === 'Choice') {
  89. const bodyOptions = (body as BodyChoice).items;
  90. if (bodyOptions.length === 0) return null;
  91. // The default option is listed first; take that.
  92. return this.renderBody(bodyOptions[0]);
  93. }
  94. if ('source' in body) {
  95. // The body is a Specific Resource.
  96. // TODO Try render exactly that part of the resource indicated by body.selector.
  97. return this.renderIframe(body.source);
  98. }
  99. // The body is an External Web Resource. Depending on its type, render an appropriate element.
  100. if (
  101. asArray(body.format).every((item) => item.startsWith('image/')) ||
  102. asArray(body.type).every((item) => item === 'Image')
  103. ) {
  104. return this.renderImage(body.id);
  105. }
  106. if (
  107. asArray(body.format).every((item) => item.startsWith('audio/')) ||
  108. asArray(body.type).every((item) => item === 'Sound')
  109. ) {
  110. return this.renderAudio(body.id);
  111. }
  112. if (
  113. asArray(body.format).every((item) => item.startsWith('video/')) ||
  114. asArray(body.type).every((item) => item === 'Video')
  115. ) {
  116. return this.renderAudio(body.id);
  117. }
  118. return this.renderIframe(body.id);
  119. }
  120. renderImage(bodyUrl: string) {
  121. return <img src={bodyUrl}></img>;
  122. }
  123. renderAudio(bodyUrl: string) {
  124. return (
  125. <audio class={classes.annotationBodyAudio} controls src={bodyUrl}></audio>
  126. );
  127. }
  128. renderVideo(bodyUrl: string) {
  129. return <video class={classes.annotationBodyVideo} src={bodyUrl}></video>;
  130. }
  131. renderIframe(bodyUrl: string) {
  132. return (
  133. <iframe
  134. class={classes.annotationBodyIframe}
  135. sandbox=""
  136. src={bodyUrl}
  137. ></iframe>
  138. );
  139. }
  140. }