stimulus-pdf-viewer-rails 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1270,6 +1270,264 @@
1270
1270
  }
1271
1271
  }
1272
1272
 
1273
+ /**
1274
+ * Base class for annotation storage implementations.
1275
+ *
1276
+ * Subclasses must implement all methods to provide persistence for annotations.
1277
+ * The AnnotationManager delegates all storage operations to a store instance.
1278
+ *
1279
+ * @example
1280
+ * class MyCustomStore extends AnnotationStore {
1281
+ * async load() { return fetch('/my-api/annotations').then(r => r.json()) }
1282
+ * async create(data) { ... }
1283
+ * // ... etc
1284
+ * }
1285
+ */
1286
+ class AnnotationStore {
1287
+ /**
1288
+ * Load all annotations.
1289
+ * @returns {Promise<Array>} Array of annotation objects
1290
+ */
1291
+ async load() {
1292
+ throw new Error("AnnotationStore.load() not implemented")
1293
+ }
1294
+
1295
+ /**
1296
+ * Create a new annotation.
1297
+ * @param {Object} data - Annotation data (without id)
1298
+ * @returns {Promise<Object>} Created annotation with server-assigned id
1299
+ */
1300
+ async create(data) {
1301
+ throw new Error("AnnotationStore.create() not implemented")
1302
+ }
1303
+
1304
+ /**
1305
+ * Update an existing annotation.
1306
+ * @param {string|number} id - Annotation id
1307
+ * @param {Object} data - Fields to update
1308
+ * @returns {Promise<Object>} Updated annotation
1309
+ */
1310
+ async update(id, data) {
1311
+ throw new Error("AnnotationStore.update() not implemented")
1312
+ }
1313
+
1314
+ /**
1315
+ * Delete an annotation.
1316
+ * @param {string|number} id - Annotation id
1317
+ * @returns {Promise<Object>} Deleted annotation
1318
+ */
1319
+ async delete(id) {
1320
+ throw new Error("AnnotationStore.delete() not implemented")
1321
+ }
1322
+
1323
+ /**
1324
+ * Restore a soft-deleted annotation.
1325
+ * @param {string|number} id - Annotation id
1326
+ * @returns {Promise<Object>} Restored annotation
1327
+ */
1328
+ async restore(id) {
1329
+ throw new Error("AnnotationStore.restore() not implemented")
1330
+ }
1331
+ }
1332
+
1333
+ /**
1334
+ * REST API annotation store with configurable URL patterns.
1335
+ *
1336
+ * By default, uses Rails-style REST conventions:
1337
+ * - GET {baseUrl}.json - load all
1338
+ * - POST {baseUrl} - create
1339
+ * - PATCH {baseUrl}/{id} - update
1340
+ * - DELETE {baseUrl}/{id} - delete
1341
+ * - PATCH {baseUrl}/{id}/restore - restore
1342
+ *
1343
+ * URL patterns can be customized via function options:
1344
+ *
1345
+ * @example
1346
+ * // Rails default (just provide baseUrl)
1347
+ * new RestAnnotationStore({ baseUrl: '/documents/123/annotations' })
1348
+ *
1349
+ * @example
1350
+ * // Custom URL patterns
1351
+ * new RestAnnotationStore({
1352
+ * baseUrl: '/api/annotations',
1353
+ * loadUrl: () => '/api/annotations', // no .json suffix
1354
+ * updateUrl: (id) => `/api/annotations/${id}/edit`
1355
+ * })
1356
+ *
1357
+ * @example
1358
+ * // Fully custom URLs with closures
1359
+ * const docId = 123
1360
+ * new RestAnnotationStore({
1361
+ * loadUrl: () => `/api/v2/documents/${docId}/annotations`,
1362
+ * createUrl: () => `/api/v2/documents/${docId}/annotations`,
1363
+ * updateUrl: (id) => `/api/v2/annotations/${id}`,
1364
+ * deleteUrl: (id) => `/api/v2/annotations/${id}`,
1365
+ * restoreUrl: (id) => `/api/v2/annotations/${id}/restore`
1366
+ * })
1367
+ */
1368
+ class RestAnnotationStore extends AnnotationStore {
1369
+ /**
1370
+ * @param {Object} options
1371
+ * @param {string} [options.baseUrl] - Base URL for Rails-style defaults
1372
+ * @param {Function} [options.loadUrl] - () => string - URL for loading annotations
1373
+ * @param {Function} [options.createUrl] - () => string - URL for creating annotations
1374
+ * @param {Function} [options.updateUrl] - (id) => string - URL for updating annotations
1375
+ * @param {Function} [options.deleteUrl] - (id) => string - URL for deleting annotations
1376
+ * @param {Function} [options.restoreUrl] - (id) => string - URL for restoring annotations
1377
+ */
1378
+ constructor(options = {}) {
1379
+ super();
1380
+ this.baseUrl = options.baseUrl;
1381
+
1382
+ // Function-based URL builders with Rails-style defaults
1383
+ this.getLoadUrl = options.loadUrl || (() => `${this.baseUrl}.json`);
1384
+ this.getCreateUrl = options.createUrl || (() => this.baseUrl);
1385
+ this.getUpdateUrl = options.updateUrl || ((id) => `${this.baseUrl}/${id}`);
1386
+ this.getDeleteUrl = options.deleteUrl || ((id) => `${this.baseUrl}/${id}`);
1387
+ this.getRestoreUrl = options.restoreUrl || ((id) => `${this.baseUrl}/${id}/restore`);
1388
+ }
1389
+
1390
+ async load() {
1391
+ const request = new request_js.FetchRequest("get", this.getLoadUrl());
1392
+ const response = await request.perform();
1393
+
1394
+ if (response.ok) {
1395
+ return await response.json
1396
+ } else {
1397
+ throw new Error("Failed to load annotations")
1398
+ }
1399
+ }
1400
+
1401
+ async create(data) {
1402
+ const request = new request_js.FetchRequest("post", this.getCreateUrl(), {
1403
+ body: JSON.stringify({ annotation: data }),
1404
+ contentType: "application/json",
1405
+ responseKind: "json"
1406
+ });
1407
+
1408
+ const response = await request.perform();
1409
+
1410
+ if (response.ok) {
1411
+ return await response.json
1412
+ } else {
1413
+ throw new Error("Failed to create annotation")
1414
+ }
1415
+ }
1416
+
1417
+ async update(id, data) {
1418
+ const request = new request_js.FetchRequest("patch", this.getUpdateUrl(id), {
1419
+ body: JSON.stringify({ annotation: data }),
1420
+ contentType: "application/json",
1421
+ responseKind: "json"
1422
+ });
1423
+
1424
+ const response = await request.perform();
1425
+
1426
+ if (response.ok) {
1427
+ return await response.json
1428
+ } else {
1429
+ throw new Error("Failed to update annotation")
1430
+ }
1431
+ }
1432
+
1433
+ async delete(id) {
1434
+ const request = new request_js.FetchRequest("delete", this.getDeleteUrl(id), {
1435
+ responseKind: "json"
1436
+ });
1437
+
1438
+ const response = await request.perform();
1439
+
1440
+ if (response.ok) {
1441
+ return await response.json
1442
+ } else {
1443
+ throw new Error("Failed to delete annotation")
1444
+ }
1445
+ }
1446
+
1447
+ async restore(id) {
1448
+ const request = new request_js.FetchRequest("patch", this.getRestoreUrl(id), {
1449
+ responseKind: "json"
1450
+ });
1451
+
1452
+ const response = await request.perform();
1453
+
1454
+ if (response.ok) {
1455
+ return await response.json
1456
+ } else {
1457
+ throw new Error("Failed to restore annotation")
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ /**
1463
+ * In-memory annotation store for development and demo purposes.
1464
+ *
1465
+ * Annotations are stored in memory only and lost on page refresh.
1466
+ * Useful for:
1467
+ * - Local development without a backend
1468
+ * - Demo/preview modes
1469
+ * - Testing
1470
+ *
1471
+ * @example
1472
+ * new MemoryAnnotationStore()
1473
+ */
1474
+ class MemoryAnnotationStore extends AnnotationStore {
1475
+ constructor() {
1476
+ super();
1477
+ this._annotations = [];
1478
+ this._nextId = 1;
1479
+ }
1480
+
1481
+ async load() {
1482
+ return [...this._annotations]
1483
+ }
1484
+
1485
+ async create(data) {
1486
+ const annotation = {
1487
+ ...data,
1488
+ id: `local-${this._nextId++}`,
1489
+ created_at: new Date().toISOString(),
1490
+ updated_at: new Date().toISOString()
1491
+ };
1492
+
1493
+ this._annotations.push(annotation);
1494
+ return annotation
1495
+ }
1496
+
1497
+ async update(id, data) {
1498
+ const index = this._annotations.findIndex(a => a.id === id);
1499
+ if (index === -1) {
1500
+ throw new Error("Annotation not found")
1501
+ }
1502
+
1503
+ const annotation = {
1504
+ ...this._annotations[index],
1505
+ ...data,
1506
+ id, // Preserve original id
1507
+ updated_at: new Date().toISOString()
1508
+ };
1509
+
1510
+ this._annotations[index] = annotation;
1511
+ return annotation
1512
+ }
1513
+
1514
+ async delete(id) {
1515
+ const index = this._annotations.findIndex(a => a.id === id);
1516
+ if (index === -1) {
1517
+ throw new Error("Annotation not found")
1518
+ }
1519
+
1520
+ const [annotation] = this._annotations.splice(index, 1);
1521
+ return annotation
1522
+ }
1523
+
1524
+ async restore(id) {
1525
+ // Memory store doesn't support soft-delete/restore
1526
+ console.warn("MemoryAnnotationStore.restore() is not supported");
1527
+ return null
1528
+ }
1529
+ }
1530
+
1273
1531
  // Custom event types for error handling
1274
1532
  const AnnotationErrorType = {
1275
1533
  LOAD_FAILED: "load_failed",
@@ -1280,13 +1538,31 @@
1280
1538
  };
1281
1539
 
1282
1540
  class AnnotationManager {
1541
+ /**
1542
+ * @param {Object} options
1543
+ * @param {AnnotationStore} [options.store] - Custom store implementation
1544
+ * @param {string} [options.annotationsUrl] - Base URL for REST store (creates RestAnnotationStore)
1545
+ * @param {number} [options.documentId] - Document ID
1546
+ * @param {Function} [options.onAnnotationCreated] - Callback when annotation created
1547
+ * @param {Function} [options.onAnnotationUpdated] - Callback when annotation updated
1548
+ * @param {Function} [options.onAnnotationDeleted] - Callback when annotation deleted
1549
+ * @param {Element} [options.eventTarget] - Element for dispatching error events
1550
+ */
1283
1551
  constructor(options = {}) {
1284
- this.annotationsUrl = options.annotationsUrl;
1285
1552
  this.documentId = options.documentId;
1286
1553
  this.onAnnotationCreated = options.onAnnotationCreated;
1287
1554
  this.onAnnotationUpdated = options.onAnnotationUpdated;
1288
1555
  this.onAnnotationDeleted = options.onAnnotationDeleted;
1289
- this.eventTarget = options.eventTarget; // Optional element for dispatching events
1556
+ this.eventTarget = options.eventTarget;
1557
+
1558
+ // Determine store: explicit > REST URL > memory
1559
+ if (options.store) {
1560
+ this.store = options.store;
1561
+ } else if (options.annotationsUrl) {
1562
+ this.store = new RestAnnotationStore({ baseUrl: options.annotationsUrl });
1563
+ } else {
1564
+ this.store = new MemoryAnnotationStore();
1565
+ }
1290
1566
 
1291
1567
  this.annotations = new Map(); // id -> annotation
1292
1568
  this.annotationsByPage = new Map(); // pageNumber -> [annotations]
@@ -1311,15 +1587,8 @@
1311
1587
 
1312
1588
  async loadAnnotations() {
1313
1589
  try {
1314
- const request = new request_js.FetchRequest("get", `${this.annotationsUrl}.json`);
1315
- const response = await request.perform();
1316
-
1317
- if (response.ok) {
1318
- const data = await response.json;
1319
- this._processAnnotations(data);
1320
- } else {
1321
- throw new Error("Server returned an error")
1322
- }
1590
+ const annotations = await this.store.load();
1591
+ this._processAnnotations(annotations);
1323
1592
  } catch (error) {
1324
1593
  console.error("Failed to load annotations:", error);
1325
1594
  this._dispatchError(AnnotationErrorType.LOAD_FAILED, "Failed to load annotations", error);
@@ -1355,26 +1624,14 @@
1355
1624
 
1356
1625
  async createAnnotation(data) {
1357
1626
  try {
1358
- const request = new request_js.FetchRequest("post", this.annotationsUrl, {
1359
- body: JSON.stringify({ annotation: data }),
1360
- contentType: "application/json",
1361
- responseKind: "json"
1362
- });
1363
-
1364
- const response = await request.perform();
1365
-
1366
- if (response.ok) {
1367
- const annotation = await response.json;
1368
- this._addAnnotation(annotation);
1369
-
1370
- if (this.onAnnotationCreated) {
1371
- this.onAnnotationCreated(annotation);
1372
- }
1627
+ const annotation = await this.store.create(data);
1628
+ this._addAnnotation(annotation);
1373
1629
 
1374
- return annotation
1375
- } else {
1376
- throw new Error("Failed to create annotation")
1630
+ if (this.onAnnotationCreated) {
1631
+ this.onAnnotationCreated(annotation);
1377
1632
  }
1633
+
1634
+ return annotation
1378
1635
  } catch (error) {
1379
1636
  console.error("Failed to create annotation:", error);
1380
1637
  this._dispatchError(AnnotationErrorType.CREATE_FAILED, "Failed to save annotation", error);
@@ -1384,26 +1641,14 @@
1384
1641
 
1385
1642
  async updateAnnotation(id, data) {
1386
1643
  try {
1387
- const request = new request_js.FetchRequest("patch", `${this.annotationsUrl}/${id}`, {
1388
- body: JSON.stringify({ annotation: data }),
1389
- contentType: "application/json",
1390
- responseKind: "json"
1391
- });
1644
+ const annotation = await this.store.update(id, data);
1645
+ this._updateAnnotation(annotation);
1392
1646
 
1393
- const response = await request.perform();
1394
-
1395
- if (response.ok) {
1396
- const annotation = await response.json;
1397
- this._updateAnnotation(annotation);
1398
-
1399
- if (this.onAnnotationUpdated) {
1400
- this.onAnnotationUpdated(annotation);
1401
- }
1402
-
1403
- return annotation
1404
- } else {
1405
- throw new Error("Failed to update annotation")
1647
+ if (this.onAnnotationUpdated) {
1648
+ this.onAnnotationUpdated(annotation);
1406
1649
  }
1650
+
1651
+ return annotation
1407
1652
  } catch (error) {
1408
1653
  console.error("Failed to update annotation:", error);
1409
1654
  this._dispatchError(AnnotationErrorType.UPDATE_FAILED, "Failed to update annotation", error);
@@ -1412,26 +1657,18 @@
1412
1657
  }
1413
1658
 
1414
1659
  async deleteAnnotation(id) {
1415
- try {
1416
- const annotation = this.annotations.get(id);
1417
- if (!annotation) return
1660
+ const existingAnnotation = this.annotations.get(id);
1661
+ if (!existingAnnotation) return
1418
1662
 
1419
- const request = new request_js.FetchRequest("delete", `${this.annotationsUrl}/${id}`, {
1420
- responseKind: "json"
1421
- });
1422
- const response = await request.perform();
1423
-
1424
- if (response.ok) {
1425
- this._removeAnnotation(id);
1426
-
1427
- if (this.onAnnotationDeleted) {
1428
- this.onAnnotationDeleted(annotation);
1429
- }
1663
+ try {
1664
+ const annotation = await this.store.delete(id);
1665
+ this._removeAnnotation(id);
1430
1666
 
1431
- return annotation
1432
- } else {
1433
- throw new Error("Failed to delete annotation")
1667
+ if (this.onAnnotationDeleted) {
1668
+ this.onAnnotationDeleted(existingAnnotation);
1434
1669
  }
1670
+
1671
+ return existingAnnotation
1435
1672
  } catch (error) {
1436
1673
  console.error("Failed to delete annotation:", error);
1437
1674
  this._dispatchError(AnnotationErrorType.DELETE_FAILED, "Failed to delete annotation", error);
@@ -1441,23 +1678,16 @@
1441
1678
 
1442
1679
  async restoreAnnotation(id) {
1443
1680
  try {
1444
- const request = new request_js.FetchRequest("patch", `${this.annotationsUrl}/${id}/restore`, {
1445
- responseKind: "json"
1446
- });
1447
- const response = await request.perform();
1448
-
1449
- if (response.ok) {
1450
- const annotation = await response.json;
1451
- this._addAnnotation(annotation);
1681
+ const annotation = await this.store.restore(id);
1682
+ if (!annotation) return null
1452
1683
 
1453
- if (this.onAnnotationCreated) {
1454
- this.onAnnotationCreated(annotation);
1455
- }
1684
+ this._addAnnotation(annotation);
1456
1685
 
1457
- return annotation
1458
- } else {
1459
- throw new Error("Failed to restore annotation")
1686
+ if (this.onAnnotationCreated) {
1687
+ this.onAnnotationCreated(annotation);
1460
1688
  }
1689
+
1690
+ return annotation
1461
1691
  } catch (error) {
1462
1692
  console.error("Failed to restore annotation:", error);
1463
1693
  this._dispatchError(AnnotationErrorType.RESTORE_FAILED, "Failed to restore annotation", error);
@@ -2130,6 +2360,11 @@
2130
2360
  <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
2131
2361
  </svg>`,
2132
2362
 
2363
+ // Comment/Speech bubble icon - used in annotation edit toolbar
2364
+ comment: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2365
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
2366
+ </svg>`,
2367
+
2133
2368
  // Chevron down - used in color pickers, dropdowns
2134
2369
  chevronDown: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
2135
2370
  <polyline points="6 9 12 15 18 9"/>
@@ -2264,6 +2499,7 @@
2264
2499
  this.onColorChange = options.onColorChange;
2265
2500
  this.onDelete = options.onDelete;
2266
2501
  this.onEdit = options.onEdit;
2502
+ this.onComment = options.onComment;
2267
2503
  this.onDeselect = options.onDeselect;
2268
2504
  this.colors = options.colors || ColorPicker.COLORS.map(c => c.value);
2269
2505
 
@@ -2280,6 +2516,9 @@
2280
2516
  this.element.className = "annotation-edit-toolbar hidden";
2281
2517
  this.element.innerHTML = `
2282
2518
  <div class="toolbar-buttons">
2519
+ <button class="toolbar-btn comment-btn hidden" title="Add Comment (C)">
2520
+ ${Icons.comment}
2521
+ </button>
2283
2522
  <button class="color-picker-btn" title="Change color" aria-haspopup="true" aria-expanded="false">
2284
2523
  <span class="color-swatch"></span>
2285
2524
  ${Icons.chevronDown}
@@ -2299,11 +2538,12 @@
2299
2538
  ${Icons.delete}
2300
2539
  </button>
2301
2540
  </div>
2302
- <div class="toolbar-note-content hidden"></div>
2541
+ <div class="toolbar-annotation-content hidden"></div>
2303
2542
  `;
2304
2543
 
2544
+ this.commentBtn = this.element.querySelector(".comment-btn");
2305
2545
  this.editBtn = this.element.querySelector(".edit-btn");
2306
- this.noteContent = this.element.querySelector(".toolbar-note-content");
2546
+ this.annotationContent = this.element.querySelector(".toolbar-annotation-content");
2307
2547
  }
2308
2548
 
2309
2549
  _setupEventListeners() {
@@ -2324,7 +2564,15 @@
2324
2564
  });
2325
2565
  });
2326
2566
 
2327
- // Edit button
2567
+ // Comment button (for highlight/underline/ink annotations)
2568
+ this.commentBtn.addEventListener("click", (e) => {
2569
+ e.stopPropagation();
2570
+ if (this.currentAnnotation && this.onComment) {
2571
+ this.onComment(this.currentAnnotation);
2572
+ }
2573
+ });
2574
+
2575
+ // Edit button (for notes)
2328
2576
  this.editBtn.addEventListener("click", (e) => {
2329
2577
  e.stopPropagation();
2330
2578
  if (this.currentAnnotation && this.onEdit) {
@@ -2377,6 +2625,13 @@
2377
2625
  e.preventDefault();
2378
2626
  this.onEdit(this.currentAnnotation);
2379
2627
  }
2628
+ } else if (e.key === "c" || e.key === "C") {
2629
+ // Comment shortcut for highlight/underline/ink annotations
2630
+ const supportsComment = ["highlight", "line", "ink"].includes(this.currentAnnotation?.annotation_type);
2631
+ if (supportsComment && this.onComment) {
2632
+ e.preventDefault();
2633
+ this.onComment(this.currentAnnotation);
2634
+ }
2380
2635
  }
2381
2636
  });
2382
2637
  }
@@ -2432,15 +2687,26 @@
2432
2687
  const color = annotation.color || ColorPicker.DEFAULT_HIGHLIGHT_COLOR;
2433
2688
  this._updateSelectedColor(color);
2434
2689
 
2435
- // Show/hide edit button and note content based on annotation type
2690
+ // Show/hide buttons based on annotation type
2436
2691
  const isNote = annotation.annotation_type === "note";
2692
+ const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
2693
+
2694
+ // Comment button for highlight/underline/ink, edit button for notes
2695
+ this.commentBtn.classList.toggle("hidden", !supportsComment);
2437
2696
  this.editBtn.classList.toggle("hidden", !isNote);
2438
2697
 
2439
- if (isNote && annotation.contents) {
2440
- this.noteContent.textContent = annotation.contents;
2441
- this.noteContent.classList.remove("hidden");
2698
+ // Update comment button title based on whether contents exists
2699
+ if (supportsComment) {
2700
+ const hasComment = annotation.contents && annotation.contents.trim();
2701
+ this.commentBtn.title = hasComment ? "Edit Comment (C)" : "Add Comment (C)";
2702
+ }
2703
+
2704
+ // Show contents for any annotation type that has it
2705
+ if (annotation.contents) {
2706
+ this.annotationContent.textContent = annotation.contents;
2707
+ this.annotationContent.classList.remove("hidden");
2442
2708
  } else {
2443
- this.noteContent.classList.add("hidden");
2709
+ this.annotationContent.classList.add("hidden");
2444
2710
  }
2445
2711
 
2446
2712
  // Determine if toolbar should flip above the annotation
@@ -2461,9 +2727,9 @@
2461
2727
  this.element.classList.add("hidden");
2462
2728
  this.currentAnnotation = null;
2463
2729
 
2464
- // Clear note content
2465
- this.noteContent.textContent = "";
2466
- this.noteContent.classList.add("hidden");
2730
+ // Clear annotation content
2731
+ this.annotationContent.textContent = "";
2732
+ this.annotationContent.classList.add("hidden");
2467
2733
 
2468
2734
  // Remove from parent when hidden
2469
2735
  if (this.element.parentNode) {
@@ -3157,10 +3423,10 @@
3157
3423
  };
3158
3424
 
3159
3425
  class AnnotationSidebar {
3160
- constructor({ container, annotationManager, onAnnotationClick }) {
3161
- this.container = container;
3426
+ constructor({ element, itemTemplate, container, annotationManager, onAnnotationClick }) {
3162
3427
  this.annotationManager = annotationManager;
3163
3428
  this.onAnnotationClick = onAnnotationClick;
3429
+ this.itemTemplate = itemTemplate; // Optional <template> element for custom list items
3164
3430
 
3165
3431
  this.isOpen = false;
3166
3432
  this.sidebarWidth = SIDEBAR_DEFAULT_WIDTH;
@@ -3168,7 +3434,30 @@
3168
3434
  this.filterType = FilterType.ALL;
3169
3435
  this.selectedAnnotationId = null;
3170
3436
 
3171
- this._createElements();
3437
+ if (element) {
3438
+ // User provided HTML - find elements via data attributes
3439
+ this.element = element;
3440
+ this.container = element.parentElement;
3441
+ this.listContainer = element.querySelector('[data-role="list"]');
3442
+ this.header = element.querySelector('.pdf-sidebar-header');
3443
+ this.emptyState = element.querySelector('[data-role="empty-state"]');
3444
+ this.sortControls = element.querySelector('[data-role="sort-controls"]');
3445
+ this.filterControls = element.querySelector('[data-role="filter-controls"]');
3446
+ this.resizer = element.querySelector('[data-role="resizer"]');
3447
+
3448
+ // Read initial width from CSS variable if set
3449
+ const currentWidth = element.style.getPropertyValue('--sidebar-width');
3450
+ if (currentWidth) {
3451
+ this.sidebarWidth = parseInt(currentWidth, 10) || SIDEBAR_DEFAULT_WIDTH;
3452
+ } else {
3453
+ element.style.setProperty('--sidebar-width', `${this.sidebarWidth}px`);
3454
+ }
3455
+ } else {
3456
+ // Fallback - create default HTML (existing behavior)
3457
+ this.container = container;
3458
+ this._createElements();
3459
+ }
3460
+
3172
3461
  this._setupEventListeners();
3173
3462
  }
3174
3463
 
@@ -3251,27 +3540,35 @@
3251
3540
  }
3252
3541
 
3253
3542
  _setupEventListeners() {
3254
- // Close button
3255
- const closeBtn = this.header.querySelector(".pdf-sidebar-close");
3256
- closeBtn.addEventListener("click", () => this.close());
3543
+ // Close button - support both user HTML (data-action="close") and auto-generated (.pdf-sidebar-close)
3544
+ const closeBtn = this.header?.querySelector('[data-action="close"]') ||
3545
+ this.header?.querySelector(".pdf-sidebar-close");
3546
+ if (closeBtn) {
3547
+ closeBtn.addEventListener("click", () => this.close());
3548
+ }
3257
3549
 
3258
3550
  // Sort buttons
3259
- this.sortControls.addEventListener("click", (e) => {
3260
- const btn = e.target.closest(".sort-btn");
3261
- if (btn) {
3262
- this.sortMode = btn.dataset.sort;
3263
- this.sortControls.querySelectorAll(".sort-btn").forEach(b => b.classList.remove("active"));
3264
- btn.classList.add("active");
3265
- this._refreshList();
3266
- }
3267
- });
3551
+ if (this.sortControls) {
3552
+ this.sortControls.addEventListener("click", (e) => {
3553
+ const btn = e.target.closest("[data-sort]") || e.target.closest(".sort-btn");
3554
+ if (btn && btn.dataset.sort) {
3555
+ this.sortMode = btn.dataset.sort;
3556
+ this.sortControls.querySelectorAll("[data-sort], .sort-btn").forEach(b => b.classList.remove("active"));
3557
+ btn.classList.add("active");
3558
+ this._refreshList();
3559
+ }
3560
+ });
3561
+ }
3268
3562
 
3269
- // Filter select
3270
- const filterSelect = this.filterControls.querySelector(".annotation-filter-select");
3271
- filterSelect.addEventListener("change", (e) => {
3272
- this.filterType = e.target.value;
3273
- this._refreshList();
3274
- });
3563
+ // Filter select - support both user HTML (data-action="filter") and auto-generated (.annotation-filter-select)
3564
+ const filterSelect = this.filterControls?.querySelector('[data-action="filter"]') ||
3565
+ this.filterControls?.querySelector(".annotation-filter-select");
3566
+ if (filterSelect) {
3567
+ filterSelect.addEventListener("change", (e) => {
3568
+ this.filterType = e.target.value;
3569
+ this._refreshList();
3570
+ });
3571
+ }
3275
3572
 
3276
3573
  // Sidebar resizing
3277
3574
  this._setupResizer();
@@ -3283,6 +3580,8 @@
3283
3580
  }
3284
3581
 
3285
3582
  _setupResizer() {
3583
+ if (!this.resizer) return
3584
+
3286
3585
  let startX, startWidth;
3287
3586
 
3288
3587
  const onMouseMove = (e) => {
@@ -3366,16 +3665,19 @@
3366
3665
  // Clear and rebuild list
3367
3666
  this.listContainer.innerHTML = "";
3368
3667
 
3369
- // Update count badge
3370
- const countBadge = this.header.querySelector(".annotation-count-badge");
3371
- countBadge.textContent = annotations.length;
3668
+ // Update count badge - support both user HTML (data-role="count") and auto-generated
3669
+ const countBadge = this.header?.querySelector('[data-role="count"]') ||
3670
+ this.header?.querySelector(".annotation-count-badge");
3671
+ if (countBadge) {
3672
+ countBadge.textContent = annotations.length;
3673
+ }
3372
3674
 
3373
3675
  // Show empty state or list
3374
3676
  if (annotations.length === 0) {
3375
- this.emptyState.classList.add("visible");
3677
+ this.emptyState?.classList.add("visible");
3376
3678
  this.listContainer.classList.add("empty");
3377
3679
  } else {
3378
- this.emptyState.classList.remove("visible");
3680
+ this.emptyState?.classList.remove("visible");
3379
3681
  this.listContainer.classList.remove("empty");
3380
3682
 
3381
3683
  for (const annotation of annotations) {
@@ -3444,40 +3746,71 @@
3444
3746
  }
3445
3747
 
3446
3748
  _createListItem(annotation) {
3447
- const item = document.createElement("div");
3448
- item.className = "annotation-list-item";
3449
- item.dataset.annotationId = annotation.id;
3450
- item.tabIndex = 0;
3749
+ let item;
3451
3750
 
3452
- if (this.selectedAnnotationId === annotation.id) {
3453
- item.classList.add("selected");
3454
- }
3751
+ if (this.itemTemplate) {
3752
+ // Clone user's template and populate data-field elements
3753
+ item = this.itemTemplate.content.firstElementChild.cloneNode(true);
3754
+ item.dataset.annotationId = annotation.id;
3455
3755
 
3456
- // Determine icon and label based on type
3457
- const { icon, label, typeLabel } = this._getAnnotationDisplay(annotation);
3756
+ // Ensure tabIndex for keyboard navigation
3757
+ if (!item.hasAttribute("tabindex")) {
3758
+ item.tabIndex = 0;
3759
+ }
3458
3760
 
3459
- // Format timestamp
3460
- const timestamp = this._formatTimestamp(annotation.created_at);
3761
+ // Determine display values
3762
+ const { icon, label, typeLabel } = this._getAnnotationDisplay(annotation);
3763
+ const timestamp = this._formatTimestamp(annotation.created_at);
3764
+
3765
+ // Populate data-field elements
3766
+ this._setField(item, "icon", icon, annotation.color);
3767
+ this._setField(item, "label", this._escapeHtml(label));
3768
+ this._setField(item, "type", typeLabel);
3769
+ this._setField(item, "page", `Page ${annotation.page}`);
3770
+ this._setField(item, "time", timestamp);
3771
+
3772
+ // Also set data attributes for user's Stimulus controllers
3773
+ item.dataset.annotationType = annotation.annotation_type;
3774
+ item.dataset.annotationPage = annotation.page;
3775
+ item.dataset.annotationColor = annotation.color || "";
3776
+ } else {
3777
+ // Fallback - existing innerHTML approach
3778
+ item = document.createElement("div");
3779
+ item.className = "annotation-list-item";
3780
+ item.dataset.annotationId = annotation.id;
3781
+ item.tabIndex = 0;
3461
3782
 
3462
- item.innerHTML = `
3463
- <div class="annotation-item-icon" style="color: ${annotation.color || '#666'}">
3464
- ${icon}
3465
- </div>
3466
- <div class="annotation-item-content">
3467
- <div class="annotation-item-label">${this._escapeHtml(label)}</div>
3468
- <div class="annotation-item-meta">
3469
- <span class="annotation-item-type">${typeLabel}</span>
3470
- <span class="annotation-item-separator">•</span>
3471
- <span class="annotation-item-page">Page ${annotation.page}</span>
3472
- <span class="annotation-item-separator">•</span>
3473
- <span class="annotation-item-time">${timestamp}</span>
3783
+ // Determine icon and label based on type
3784
+ const { icon, label, typeLabel } = this._getAnnotationDisplay(annotation);
3785
+
3786
+ // Format timestamp
3787
+ const timestamp = this._formatTimestamp(annotation.created_at);
3788
+
3789
+ item.innerHTML = `
3790
+ <div class="annotation-item-icon" style="color: ${annotation.color || '#666'}">
3791
+ ${icon}
3474
3792
  </div>
3475
- </div>
3476
- <div class="annotation-item-hover">
3477
- <span>Jump</span>
3478
- ${Icons.chevronRight}
3479
- </div>
3480
- `;
3793
+ <div class="annotation-item-content">
3794
+ <div class="annotation-item-label">${this._escapeHtml(label)}</div>
3795
+ <div class="annotation-item-meta">
3796
+ <span class="annotation-item-type">${typeLabel}</span>
3797
+ <span class="annotation-item-separator">•</span>
3798
+ <span class="annotation-item-page">Page ${annotation.page}</span>
3799
+ <span class="annotation-item-separator">•</span>
3800
+ <span class="annotation-item-time">${timestamp}</span>
3801
+ </div>
3802
+ </div>
3803
+ <div class="annotation-item-hover">
3804
+ <span>Jump</span>
3805
+ ${Icons.chevronRight}
3806
+ </div>
3807
+ `;
3808
+ }
3809
+
3810
+ // Selection state
3811
+ if (this.selectedAnnotationId === annotation.id) {
3812
+ item.classList.add("selected");
3813
+ }
3481
3814
 
3482
3815
  // Click handler
3483
3816
  item.addEventListener("click", () => {
@@ -3490,6 +3823,23 @@
3490
3823
  return item
3491
3824
  }
3492
3825
 
3826
+ /**
3827
+ * Set a field value in a template-cloned element
3828
+ * @param {HTMLElement} element - The cloned template element
3829
+ * @param {string} fieldName - The data-field name to find
3830
+ * @param {string} value - The value to set (can include HTML for icons)
3831
+ * @param {string} color - Optional color to apply
3832
+ */
3833
+ _setField(element, fieldName, value, color) {
3834
+ const field = element.querySelector(`[data-field="${fieldName}"]`);
3835
+ if (field) {
3836
+ field.innerHTML = value;
3837
+ if (color && fieldName === "icon") {
3838
+ field.style.color = color;
3839
+ }
3840
+ }
3841
+ }
3842
+
3493
3843
  _getAnnotationDisplay(annotation) {
3494
3844
  const type = annotation.annotation_type;
3495
3845
  let icon, label, typeLabel;
@@ -4864,15 +5214,25 @@
4864
5214
  for (let i = 0; i < result.length; i++) {
4865
5215
  const existing = result[i];
4866
5216
 
4867
- // Check if rects overlap (share vertical AND horizontal space)
4868
- const verticalOverlap = rect.top < existing.bottom && rect.bottom > existing.top;
5217
+ // Calculate vertical overlap amount
5218
+ const overlapTop = Math.max(rect.top, existing.top);
5219
+ const overlapBottom = Math.min(rect.bottom, existing.bottom);
5220
+ const overlapHeight = Math.max(0, overlapBottom - overlapTop);
5221
+
5222
+ // Require significant vertical overlap (at least 50% of smaller rect's height)
5223
+ // This prevents merging rects from different lines that only slightly overlap
5224
+ const rectHeight = rect.bottom - rect.top;
5225
+ const existingHeight = existing.bottom - existing.top;
5226
+ const minHeight = Math.min(rectHeight, existingHeight);
5227
+ const significantVerticalOverlap = overlapHeight > minHeight * 0.5;
5228
+
4869
5229
  const horizontalOverlap = rect.left < existing.right && rect.right > existing.left;
4870
5230
 
4871
5231
  // Also merge if they're adjacent horizontally on same line
4872
5232
  const sameLine = Math.abs(rect.top - existing.top) < 3 && Math.abs(rect.bottom - existing.bottom) < 3;
4873
5233
  const horizontallyAdjacent = Math.abs(rect.left - existing.right) < 2 || Math.abs(existing.left - rect.right) < 2;
4874
5234
 
4875
- if ((verticalOverlap && horizontalOverlap) || (sameLine && horizontallyAdjacent)) {
5235
+ if ((significantVerticalOverlap && horizontalOverlap) || (sameLine && horizontallyAdjacent)) {
4876
5236
  // Merge: extend existing rect to encompass both
4877
5237
  result[i] = {
4878
5238
  left: Math.min(existing.left, rect.left),
@@ -5837,12 +6197,12 @@
5837
6197
  });
5838
6198
  }
5839
6199
 
5840
- // Method to edit an existing note
6200
+ // Method to edit an existing note or add/edit a comment on other annotation types
5841
6201
  editNote(annotation) {
5842
6202
  // Store currently focused element for restoration on close
5843
6203
  this._previousFocusElement = document.activeElement;
5844
6204
 
5845
- // Get the position of the note on screen
6205
+ // Get the position of the annotation on screen
5846
6206
  const pageContainer = this.viewer.getPageContainer(annotation.page);
5847
6207
  if (!pageContainer) return
5848
6208
 
@@ -5854,10 +6214,20 @@
5854
6214
  // Store the annotation being edited
5855
6215
  this.editingAnnotation = annotation;
5856
6216
 
5857
- this._showEditDialog(rect.left + x, rect.top + y, annotation.contents);
6217
+ // Determine dialog title based on annotation type and whether contents exists
6218
+ const isNote = annotation.annotation_type === "note";
6219
+ const hasContents = annotation.contents && annotation.contents.trim();
6220
+ let dialogTitle;
6221
+ if (isNote) {
6222
+ dialogTitle = "Edit Note";
6223
+ } else {
6224
+ dialogTitle = hasContents ? "Edit Comment" : "Add Comment";
6225
+ }
6226
+
6227
+ this._showEditDialog(rect.left + x, rect.top + y, annotation.contents, dialogTitle);
5858
6228
  }
5859
6229
 
5860
- _showEditDialog(x, y, existingText) {
6230
+ _showEditDialog(x, y, existingText, title = "Edit Note") {
5861
6231
  // Remove any existing dialog (but keep editingAnnotation)
5862
6232
  this._removeDialog();
5863
6233
 
@@ -5866,7 +6236,7 @@
5866
6236
  this.noteDialog.className = "note-dialog";
5867
6237
  this.noteDialog.innerHTML = `
5868
6238
  <div class="note-dialog-header">
5869
- <span>Edit Note</span>
6239
+ <span>${title}</span>
5870
6240
  <button class="note-dialog-close" aria-label="Close">
5871
6241
  ${Icons.close}
5872
6242
  </button>
@@ -6348,7 +6718,9 @@
6348
6718
  this._setupViewerEvents();
6349
6719
 
6350
6720
  // Annotation manager for CRUD operations
6721
+ // Accepts custom store, falls back to REST store if URL provided, else memory store
6351
6722
  this.annotationManager = new AnnotationManager({
6723
+ store: this.options.annotationStore,
6352
6724
  annotationsUrl: this.annotationsUrl,
6353
6725
  documentId: this.documentId,
6354
6726
  eventTarget: this.container, // For dispatching error events
@@ -6374,6 +6746,7 @@
6374
6746
  onColorChange: this._onAnnotationColorChange.bind(this),
6375
6747
  onDelete: this._onAnnotationDelete.bind(this),
6376
6748
  onEdit: this._onAnnotationEdit.bind(this),
6749
+ onComment: this._onAnnotationComment.bind(this),
6377
6750
  onDeselect: this._deselectAnnotation.bind(this)
6378
6751
  });
6379
6752
 
@@ -6394,9 +6767,14 @@
6394
6767
  onPageClick: (pageNumber) => this.viewer.goToPage(pageNumber)
6395
6768
  });
6396
6769
 
6397
- // Annotation sidebar (inserted after pages container in the body)
6770
+ // Annotation sidebar - check for user-defined element, fallback to auto-generated
6771
+ const annotationSidebarEl = this.container.querySelector('[data-pdf-sidebar="annotations"]');
6772
+ const annotationItemTemplate = this.container.querySelector('[data-pdf-template="annotation-item"]');
6773
+
6398
6774
  this.annotationSidebar = new AnnotationSidebar({
6399
- container: this.bodyContainer,
6775
+ element: annotationSidebarEl, // null if not provided (triggers fallback)
6776
+ itemTemplate: annotationItemTemplate, // null if not provided (uses innerHTML)
6777
+ container: this.bodyContainer, // Used for fallback
6400
6778
  annotationManager: this.annotationManager,
6401
6779
  onAnnotationClick: (annotationId) => this._scrollToAnnotationWithFlash(annotationId)
6402
6780
  });
@@ -6558,7 +6936,7 @@
6558
6936
  await this.thumbnailSidebar.setDocument(this.viewer.pdfDocument);
6559
6937
  }
6560
6938
 
6561
- // Load existing annotations
6939
+ // Load existing annotations from store
6562
6940
  await this.annotationManager.loadAnnotations();
6563
6941
 
6564
6942
  // Render annotations on all rendered pages
@@ -6777,6 +7155,14 @@
6777
7155
  }
6778
7156
  }
6779
7157
 
7158
+ _onAnnotationComment(annotation) {
7159
+ // For highlight/underline/ink, use the note tool's edit dialog to edit contents
7160
+ const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
7161
+ if (supportsComment) {
7162
+ this.tools[ToolMode.NOTE].editNote(annotation);
7163
+ }
7164
+ }
7165
+
6780
7166
  async _onAnnotationDelete(annotation) {
6781
7167
  await this.annotationManager.deleteAnnotation(annotation.id);
6782
7168
  }
@@ -7590,7 +7976,8 @@
7590
7976
  documentId: String,
7591
7977
  trackingUrl: String,
7592
7978
  initialPage: Number,
7593
- initialAnnotation: String
7979
+ initialAnnotation: String,
7980
+ autoHeight: { type: Boolean, default: true }
7594
7981
  }
7595
7982
 
7596
7983
  initialize() {
@@ -8056,8 +8443,8 @@
8056
8443
 
8057
8444
  setViewportHeight() {
8058
8445
  requestAnimationFrame(() => {
8059
- // Skip if using CSS flexbox layout (document-fullscreen wrapper handles sizing)
8060
- if (this.containerTarget.closest('.document-fullscreen')) {
8446
+ // Skip if autoHeight is disabled (container height managed by consuming application)
8447
+ if (!this.autoHeightValue) {
8061
8448
  return
8062
8449
  }
8063
8450
 
@@ -8144,46 +8531,13 @@
8144
8531
  }
8145
8532
  }
8146
8533
 
8147
- class pdf_sync_scroll_controller extends stimulus.Controller {
8148
- static targets = ["container", "toggle"]
8149
-
8150
- connect() {
8151
- this.isSyncing = false;
8152
- }
8153
-
8154
- sync(event) {
8155
- if (this.isSyncing) return
8156
- if (!this.toggleTarget.checked) return
8157
-
8158
- const master = event.currentTarget;
8159
- const slave = this.containerTargets.find(target => target !== master);
8160
-
8161
- if (!slave) return
8162
-
8163
- this.isSyncing = true;
8164
-
8165
- // Calculate percentage-based scroll to account for potential
8166
- // differences in zoom levels or page counts
8167
- const scrollPercentageY = master.scrollTop / (master.scrollHeight - master.clientHeight);
8168
- const scrollPercentageX = master.scrollLeft / (master.scrollWidth - master.clientWidth);
8169
-
8170
- window.requestAnimationFrame(() => {
8171
- slave.scrollTop = scrollPercentageY * (slave.scrollHeight - slave.clientHeight);
8172
- slave.scrollLeft = scrollPercentageX * (slave.scrollWidth - slave.clientWidth);
8173
-
8174
- // Reset the lock after the browser paint
8175
- window.requestAnimationFrame(() => {
8176
- this.isSyncing = false;
8177
- });
8178
- });
8179
- }
8180
- }
8181
-
8534
+ exports.AnnotationStore = AnnotationStore;
8182
8535
  exports.CoreViewer = CoreViewer;
8536
+ exports.MemoryAnnotationStore = MemoryAnnotationStore;
8183
8537
  exports.PdfDownloadController = pdf_download_controller;
8184
- exports.PdfSyncScrollController = pdf_sync_scroll_controller;
8185
8538
  exports.PdfViewer = PdfViewer;
8186
8539
  exports.PdfViewerController = pdf_viewer_controller;
8540
+ exports.RestAnnotationStore = RestAnnotationStore;
8187
8541
  exports.ToolMode = ToolMode;
8188
8542
  exports.ViewerEvents = ViewerEvents;
8189
8543