Browse Source

added View All Annotations tab

main
astanley 2 weeks ago
parent
commit
6d94af55de
  1. 70
      README.md
  2. 26
      build/src/index.js
  3. 9
      build/vite.config.js
  4. 234
      dist/main.js
  5. 61
      islandora_mirador_annotations.install
  6. 9
      islandora_mirador_annotations.libraries.yml
  7. 12
      islandora_mirador_annotations.module
  8. 8
      islandora_mirador_annotations.routing.yml
  9. 105
      src/Controller/AnnotationController.php

70
README.md

@ -2,34 +2,32 @@
Standalone Drupal add-on for `islandora_mirador` that adds persistent Mirador 4 annotations using Mirador Annotation Editor (MAE). Standalone Drupal add-on for `islandora_mirador` that adds persistent Mirador 4 annotations using Mirador Annotation Editor (MAE).
## Features
- Leaves the base `islandora_mirador` module untouched.
- Drupal-backed persistent Web Annotations.
- Existing MAE per-canvas annotation editor/sidebar.
- **All annotations** sidebar for multi-page manifests.
- All-annotations results are grouped by canvas/page label and searchable.
- Clicking an annotation in the all-annotations panel jumps Mirador to that canvas.
- A single Drupal query loads annotations for the complete manifest; it does not make one Drupal request per page.
## Architecture ## Architecture
The base `islandora_mirador` module is not patched. This module: This module alters the base viewer library dependency so the viewer loads this module's annotated Mirador bundle. The bundle contains Mirador, the normal Islandora image-tools/text-overlay/download plugins, MAE, a Drupal persistence adapter, and the All Annotations companion-window plugin.
1. Alters the base viewer library dependency so the viewer loads this module's annotated Mirador bundle. MAE's incompatible `annotationSagaPlugin` is excluded and its canvas-loading behavior is replaced locally.
2. Builds that bundle from the same Mirador 4 plugins used by Islandora plus MAE.
3. Excludes MAE's currently incompatible `annotationSagaPlugin` and replaces only its annotation-loading behavior.
4. Adds a Drupal-backed MAE adapter.
5. Stores each Web Annotation as JSON in `islandora_mirador_annotation`.
6. Exposes an IIIF `AnnotationPage` at `/islandora-mirador/annotations?canvas=...`.
7. Protects POST/PATCH/DELETE with Drupal permissions and `X-CSRF-Token`.
## Install ## Install
Place this directory next to `islandora_mirador`, then enable it:
```bash ```bash
drush en islandora_mirador_annotations -y drush en islandora_mirador_annotations -y
drush cr drush cr
``` ```
Grant permissions as appropriate. For example, to let anonymous and authenticated users see annotations, grant `view mirador annotations` to those roles. Grant `manage mirador annotations` only to roles that may create/edit/delete annotations. Grant `view mirador annotations` to roles that may see annotations and `manage mirador annotations` to roles that may create/edit/delete them.
The module automatically sets MAE to read-only when the current user does not have `manage mirador annotations`.
## Build `dist/main.js` ## Build assets
The build project is retained under `build/`.
```bash ```bash
cd build cd build
@ -37,30 +35,38 @@ npm install
npm run build npm run build
``` ```
Vite writes the bundle to `../dist/main.js`. Vite writes:
The bundle intentionally contains Mirador itself, the Islandora image-tools/text-overlay/download plugins, and MAE in one dependency graph. This avoids loading a second React/MUI runtime beside the base Mirador bundle.
## Base module ```text
dist/main.js
dist/main.css
```
No changes to `islandora_mirador` are required. When this module is disabled, the base viewer returns to its normal `islandora_mirador/mirador` dependency after a cache rebuild. Both compiled files should be committed to the module repository so Composer users do not need Node/npm/Vite.
## Endpoint ## Endpoints
GET: Current-canvas AnnotationPage:
```text ```text
/islandora-mirador/annotations?canvas=<canvas URI> GET /islandora-mirador/annotations?canvas=<canvas URI>
``` ```
returns: Manifest-wide read endpoint used by the All Annotations panel:
```text
POST /islandora-mirador/annotations/manifest
Content-Type: application/json
```json {"canvasIds":["https://.../canvas/1","https://.../canvas/2"]}
{
"id": "...",
"type": "AnnotationPage",
"items": []
}
``` ```
MAE writes through the same endpoint using POST, PATCH, and DELETE. Annotation writes use POST/PATCH/DELETE on `/islandora-mirador/annotations` and are protected by Drupal permissions and `X-CSRF-Token`.
## All annotations click-to-focus
Selecting an entry in the **All annotations** panel switches to its canvas, selects the Mirador annotation so its target is highlighted, and, when the annotation contains an `xywh` FragmentSelector, zooms OpenSeadragon to that region with a small amount of padding. Both pixel and percent `xywh` selectors are supported.
### 0.2.5 annotation targeting
The All Annotations panel uses the Web Annotation `target.source` as the authoritative canvas when it differs from the persistence row's canvas id. It also zooms to MAE `SvgSelector` geometry when an `xywh` FragmentSelector is not available.

26
build/src/index.js

@ -6,6 +6,26 @@ import miradorDownloadPlugins from 'mirador-dl-plugin';
import textOverlayPlugin from 'mirador-textoverlay'; import textOverlayPlugin from 'mirador-textoverlay';
import annotationPlugins from 'mirador-annotation-editor'; import annotationPlugins from 'mirador-annotation-editor';
import 'mirador-annotation-editor/dist/index.css'; import 'mirador-annotation-editor/dist/index.css';
import { allAnnotationsPanelPlugin, allAnnotationsButtonPlugin } from './AllAnnotationsPanel.jsx';
function annotationTargetCanvasId(annotation) {
const target = annotation?.target;
if (typeof target === 'string') {
return target.split('#')[0] || null;
}
const source = target?.source;
if (typeof source === 'string') return source.split('#')[0] || null;
if (source?.id) return String(source.id).split('#')[0] || null;
if (source?.['@id']) return String(source['@id']).split('#')[0] || null;
if (target?.id) return String(target.id).split('#')[0] || null;
if (target?.['@id']) return String(target['@id']).split('#')[0] || null;
return null;
}
/** /**
* Drupal-backed implementation of the MAE persistence adapter contract. * Drupal-backed implementation of the MAE persistence adapter contract.
@ -83,14 +103,14 @@ class DrupalAnnotationAdapter {
async create(annotation) { async create(annotation) {
return this.request('POST', { return this.request('POST', {
canvasId: this.canvasId, canvasId: annotationTargetCanvasId(annotation) || this.canvasId,
annotation, annotation,
}); });
} }
async update(annotation) { async update(annotation) {
return this.request('PATCH', { return this.request('PATCH', {
canvasId: this.canvasId, canvasId: annotationTargetCanvasId(annotation) || this.canvasId,
annotation, annotation,
}); });
} }
@ -114,6 +134,8 @@ const plugins = [
...miradorImageToolsPlugin, ...miradorImageToolsPlugin,
...textOverlayPlugin, ...textOverlayPlugin,
...annotationUiPlugins, ...annotationUiPlugins,
allAnnotationsPanelPlugin,
allAnnotationsButtonPlugin,
...miradorDownloadPlugins, ...miradorDownloadPlugins,
]; ];

9
build/vite.config.js

@ -3,10 +3,15 @@ import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
global: 'globalThis',
},
resolve: {
dedupe: ['@emotion/react', '@emotion/styled', 'react', 'react-dom'],
},
build: { build: {
outDir: '../dist', outDir: '../dist',
emptyOutDir: true, emptyOutDir: true,
lib: { lib: {
entry: 'src/index.js', entry: 'src/index.js',
name: 'IslandoraMiradorAnnotations', name: 'IslandoraMiradorAnnotations',
@ -14,9 +19,7 @@ export default defineConfig({
fileName: () => 'main.js', fileName: () => 'main.js',
cssFileName: 'main', cssFileName: 'main',
}, },
cssCodeSplit: false, cssCodeSplit: false,
rollupOptions: { rollupOptions: {
output: { output: {
inlineDynamicImports: true, inlineDynamicImports: true,

234
dist/main.js vendored

File diff suppressed because one or more lines are too long

61
islandora_mirador_annotations.install

@ -68,3 +68,64 @@ function islandora_mirador_annotations_schema() {
return $schema; return $schema;
} }
/**
* Repair rows whose stored canvas differs from the Web Annotation target.
*/
function islandora_mirador_annotations_update_9001() {
$database = \Drupal::database();
$result = $database->select('islandora_mirador_annotation', 'a')
->fields('a', ['id', 'canvas_id', 'annotation_json'])
->execute();
$updated = 0;
foreach ($result as $row) {
$annotation = json_decode((string) $row->annotation_json, TRUE);
if (!is_array($annotation)) {
continue;
}
$target = $annotation['target'] ?? NULL;
$target_canvas_id = NULL;
if (is_string($target)) {
$target_canvas_id = explode('#', $target, 2)[0];
}
elseif (is_array($target)) {
$source = $target['source'] ?? NULL;
if (is_string($source)) {
$target_canvas_id = explode('#', $source, 2)[0];
}
elseif (is_array($source)) {
$value = $source['id'] ?? $source['@id'] ?? NULL;
if (is_string($value)) {
$target_canvas_id = explode('#', $value, 2)[0];
}
}
if (!$target_canvas_id) {
$value = $target['id'] ?? $target['@id'] ?? NULL;
if (is_string($value)) {
$target_canvas_id = explode('#', $value, 2)[0];
}
}
}
if (!$target_canvas_id || $target_canvas_id === $row->canvas_id) {
continue;
}
$database->update('islandora_mirador_annotation')
->fields([
'canvas_id' => $target_canvas_id,
'canvas_key' => hash('sha256', $target_canvas_id),
])
->condition('id', $row->id)
->execute();
$updated++;
}
return t('Re-associated @count Mirador annotation(s) with their target canvas.', [
'@count' => $updated,
]);
}

9
islandora_mirador_annotations.libraries.yml

@ -1,10 +1,7 @@
annotated_mirador: annotated_mirador:
version: 0.1.0 version: 0.2.5
js: js:
dist/main.js: dist/main.js: { minified: true, preprocess: false }
minified: true
css: css:
theme: theme:
dist/main.css: {} dist/main.css: { preprocess: false }

12
islandora_mirador_annotations.module

@ -45,11 +45,13 @@ function islandora_mirador_annotations_js_settings_alter(array &$settings, Attac
$endpoint = Url::fromRoute('islandora_mirador_annotations.collection', [], ['absolute' => TRUE])->toString(); $endpoint = Url::fromRoute('islandora_mirador_annotations.collection', [], ['absolute' => TRUE])->toString();
$can_manage = $account->hasPermission('manage mirador annotations'); $can_manage = $account->hasPermission('manage mirador annotations');
$all_endpoint = Url::fromRoute('islandora_mirador_annotations.manifest_collection', [], ['absolute' => TRUE])->toString();
foreach ($settings['mirador']['viewers'] as &$viewer) { foreach ($settings['mirador']['viewers'] as &$viewer) {
$viewer['annotation'] = [ $viewer['annotation'] = [
'adapter' => 'drupal', 'adapter' => 'drupal',
'endpoint' => $endpoint, 'endpoint' => $endpoint,
'allEndpoint' => $all_endpoint,
'csrfTokenUrl' => Url::fromRoute('system.csrftoken', [], ['absolute' => TRUE])->toString(), 'csrfTokenUrl' => Url::fromRoute('system.csrftoken', [], ['absolute' => TRUE])->toString(),
'user' => $account->isAuthenticated() ? $account->getDisplayName() : 'Anonymous', 'user' => $account->isAuthenticated() ? $account->getDisplayName() : 'Anonymous',
'readonly' => !$can_manage, 'readonly' => !$can_manage,
@ -63,5 +65,15 @@ function islandora_mirador_annotations_js_settings_alter(array &$settings, Attac
$viewer['window'] = $viewer['window'] ?? []; $viewer['window'] = $viewer['window'] ?? [];
$viewer['window']['defaultSideBarPanel'] = 'annotations'; $viewer['window']['defaultSideBarPanel'] = 'annotations';
$viewer['window']['sideBarOpenByDefault'] = TRUE;
// Keep Mirador's annotation overlay mounted even when our custom All annotations panel is open.
// Core otherwise only draws annotations while the stock annotations companion window is open.
$viewer['window']['forceDrawAnnotations'] = TRUE;
$viewer['window']['panels'] = $viewer['window']['panels'] ?? [];
$viewer['window']['panels']['allAnnotations'] = TRUE;
$viewer['translations'] = $viewer['translations'] ?? [];
$viewer['translations']['en'] = $viewer['translations']['en'] ?? [];
$viewer['translations']['en']['openCompanionWindow_allAnnotations'] = 'All annotations';
} }
} }

8
islandora_mirador_annotations.routing.yml

@ -32,3 +32,11 @@ islandora_mirador_annotations.delete:
_permission: 'manage mirador annotations' _permission: 'manage mirador annotations'
_csrf_request_header_token: 'TRUE' _csrf_request_header_token: 'TRUE'
methods: [DELETE] methods: [DELETE]
islandora_mirador_annotations.manifest_collection:
path: '/islandora-mirador/annotations/manifest'
defaults:
_controller: '\Drupal\islandora_mirador_annotations\Controller\AnnotationController::manifestCollection'
requirements:
_permission: 'view mirador annotations'
methods: [POST]

105
src/Controller/AnnotationController.php

@ -51,6 +51,66 @@ final class AnnotationController extends ControllerBase {
return new JsonResponse($this->annotationPage($canvas_id, $request)); return new JsonResponse($this->annotationPage($canvas_id, $request));
} }
/**
* Returns annotations for a set of canvases in one request.
*
* This is used by the "All annotations" manifest sidebar. It deliberately
* accepts canvas IDs rather than re-fetching the manifest on the server, so
* it also works with authenticated/proxied manifests already available to
* the browser.
*/
public function manifestCollection(Request $request): JsonResponse {
$data = $this->decodeRequest($request);
$canvas_ids = $data['canvasIds'] ?? NULL;
if (!is_array($canvas_ids)) {
throw new BadRequestHttpException('canvasIds must be an array.');
}
$canvas_ids = array_values(array_unique(array_filter(array_map(
static fn ($value) => is_string($value) ? trim($value) : '',
$canvas_ids
))));
// Keep an accidental or malicious request from building an enormous IN
// clause. IIIF manifests with thousands of canvases remain supported.
if (count($canvas_ids) > 10000) {
throw new BadRequestHttpException('Too many canvas IDs requested.');
}
if (!$canvas_ids) {
return new JsonResponse(['items' => []]);
}
$items = [];
foreach (array_chunk($canvas_ids, 500) as $chunk) {
$keys = array_map(static fn ($id) => hash('sha256', $id), $chunk);
$result = $this->database->select('islandora_mirador_annotation', 'a')
->fields('a', ['canvas_id', 'annotation_json', 'created'])
->condition('canvas_key', $keys, 'IN')
->orderBy('created', 'ASC')
->execute();
foreach ($result as $row) {
try {
$annotation = json_decode($row->annotation_json, TRUE, 512, JSON_THROW_ON_ERROR);
if (is_array($annotation)) {
$items[] = [
'canvasId' => $row->canvas_id,
'annotation' => $annotation,
];
}
}
catch (\JsonException) {
// Ignore a corrupt row rather than breaking the entire manifest list.
}
}
}
return new JsonResponse(['items' => $items]);
}
/** /**
* Creates an annotation and returns the updated AnnotationPage. * Creates an annotation and returns the updated AnnotationPage.
*/ */
@ -155,9 +215,54 @@ final class AnnotationController extends ControllerBase {
throw new BadRequestHttpException('canvasId and annotation are required.'); throw new BadRequestHttpException('canvasId and annotation are required.');
} }
// The Web Annotation target is authoritative. MAE can occasionally save
// through an adapter instance that was created for a previously active
// canvas during rapid page changes. Store the annotation against its
// actual target canvas so Mirador can retrieve and render it correctly.
$target_canvas_id = $this->targetCanvasId($annotation);
if ($target_canvas_id !== NULL) {
$canvas_id = $target_canvas_id;
}
return [$canvas_id, $annotation]; return [$canvas_id, $annotation];
} }
/**
* Returns the canvas targeted by a Web Annotation.
*/
private function targetCanvasId(array $annotation): ?string {
$target = $annotation['target'] ?? NULL;
if (is_string($target)) {
$value = explode('#', $target, 2)[0];
return $value !== '' ? $value : NULL;
}
if (!is_array($target)) {
return NULL;
}
$source = $target['source'] ?? NULL;
if (is_string($source)) {
$value = explode('#', $source, 2)[0];
return $value !== '' ? $value : NULL;
}
if (is_array($source)) {
$value = $source['id'] ?? $source['@id'] ?? NULL;
if (is_string($value) && $value !== '') {
return explode('#', $value, 2)[0];
}
}
$value = $target['id'] ?? $target['@id'] ?? NULL;
if (is_string($value) && $value !== '') {
return explode('#', $value, 2)[0];
}
return NULL;
}
/** /**
* Decodes a JSON request body. * Decodes a JSON request body.
*/ */

Loading…
Cancel
Save