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.
@@ -1250,6 +1250,264 @@ class CoreViewer {
1250
1250
  }
1251
1251
  }
1252
1252
 
1253
+ /**
1254
+ * Base class for annotation storage implementations.
1255
+ *
1256
+ * Subclasses must implement all methods to provide persistence for annotations.
1257
+ * The AnnotationManager delegates all storage operations to a store instance.
1258
+ *
1259
+ * @example
1260
+ * class MyCustomStore extends AnnotationStore {
1261
+ * async load() { return fetch('/my-api/annotations').then(r => r.json()) }
1262
+ * async create(data) { ... }
1263
+ * // ... etc
1264
+ * }
1265
+ */
1266
+ class AnnotationStore {
1267
+ /**
1268
+ * Load all annotations.
1269
+ * @returns {Promise<Array>} Array of annotation objects
1270
+ */
1271
+ async load() {
1272
+ throw new Error("AnnotationStore.load() not implemented")
1273
+ }
1274
+
1275
+ /**
1276
+ * Create a new annotation.
1277
+ * @param {Object} data - Annotation data (without id)
1278
+ * @returns {Promise<Object>} Created annotation with server-assigned id
1279
+ */
1280
+ async create(data) {
1281
+ throw new Error("AnnotationStore.create() not implemented")
1282
+ }
1283
+
1284
+ /**
1285
+ * Update an existing annotation.
1286
+ * @param {string|number} id - Annotation id
1287
+ * @param {Object} data - Fields to update
1288
+ * @returns {Promise<Object>} Updated annotation
1289
+ */
1290
+ async update(id, data) {
1291
+ throw new Error("AnnotationStore.update() not implemented")
1292
+ }
1293
+
1294
+ /**
1295
+ * Delete an annotation.
1296
+ * @param {string|number} id - Annotation id
1297
+ * @returns {Promise<Object>} Deleted annotation
1298
+ */
1299
+ async delete(id) {
1300
+ throw new Error("AnnotationStore.delete() not implemented")
1301
+ }
1302
+
1303
+ /**
1304
+ * Restore a soft-deleted annotation.
1305
+ * @param {string|number} id - Annotation id
1306
+ * @returns {Promise<Object>} Restored annotation
1307
+ */
1308
+ async restore(id) {
1309
+ throw new Error("AnnotationStore.restore() not implemented")
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * REST API annotation store with configurable URL patterns.
1315
+ *
1316
+ * By default, uses Rails-style REST conventions:
1317
+ * - GET {baseUrl}.json - load all
1318
+ * - POST {baseUrl} - create
1319
+ * - PATCH {baseUrl}/{id} - update
1320
+ * - DELETE {baseUrl}/{id} - delete
1321
+ * - PATCH {baseUrl}/{id}/restore - restore
1322
+ *
1323
+ * URL patterns can be customized via function options:
1324
+ *
1325
+ * @example
1326
+ * // Rails default (just provide baseUrl)
1327
+ * new RestAnnotationStore({ baseUrl: '/documents/123/annotations' })
1328
+ *
1329
+ * @example
1330
+ * // Custom URL patterns
1331
+ * new RestAnnotationStore({
1332
+ * baseUrl: '/api/annotations',
1333
+ * loadUrl: () => '/api/annotations', // no .json suffix
1334
+ * updateUrl: (id) => `/api/annotations/${id}/edit`
1335
+ * })
1336
+ *
1337
+ * @example
1338
+ * // Fully custom URLs with closures
1339
+ * const docId = 123
1340
+ * new RestAnnotationStore({
1341
+ * loadUrl: () => `/api/v2/documents/${docId}/annotations`,
1342
+ * createUrl: () => `/api/v2/documents/${docId}/annotations`,
1343
+ * updateUrl: (id) => `/api/v2/annotations/${id}`,
1344
+ * deleteUrl: (id) => `/api/v2/annotations/${id}`,
1345
+ * restoreUrl: (id) => `/api/v2/annotations/${id}/restore`
1346
+ * })
1347
+ */
1348
+ class RestAnnotationStore extends AnnotationStore {
1349
+ /**
1350
+ * @param {Object} options
1351
+ * @param {string} [options.baseUrl] - Base URL for Rails-style defaults
1352
+ * @param {Function} [options.loadUrl] - () => string - URL for loading annotations
1353
+ * @param {Function} [options.createUrl] - () => string - URL for creating annotations
1354
+ * @param {Function} [options.updateUrl] - (id) => string - URL for updating annotations
1355
+ * @param {Function} [options.deleteUrl] - (id) => string - URL for deleting annotations
1356
+ * @param {Function} [options.restoreUrl] - (id) => string - URL for restoring annotations
1357
+ */
1358
+ constructor(options = {}) {
1359
+ super();
1360
+ this.baseUrl = options.baseUrl;
1361
+
1362
+ // Function-based URL builders with Rails-style defaults
1363
+ this.getLoadUrl = options.loadUrl || (() => `${this.baseUrl}.json`);
1364
+ this.getCreateUrl = options.createUrl || (() => this.baseUrl);
1365
+ this.getUpdateUrl = options.updateUrl || ((id) => `${this.baseUrl}/${id}`);
1366
+ this.getDeleteUrl = options.deleteUrl || ((id) => `${this.baseUrl}/${id}`);
1367
+ this.getRestoreUrl = options.restoreUrl || ((id) => `${this.baseUrl}/${id}/restore`);
1368
+ }
1369
+
1370
+ async load() {
1371
+ const request = new FetchRequest("get", this.getLoadUrl());
1372
+ const response = await request.perform();
1373
+
1374
+ if (response.ok) {
1375
+ return await response.json
1376
+ } else {
1377
+ throw new Error("Failed to load annotations")
1378
+ }
1379
+ }
1380
+
1381
+ async create(data) {
1382
+ const request = new FetchRequest("post", this.getCreateUrl(), {
1383
+ body: JSON.stringify({ annotation: data }),
1384
+ contentType: "application/json",
1385
+ responseKind: "json"
1386
+ });
1387
+
1388
+ const response = await request.perform();
1389
+
1390
+ if (response.ok) {
1391
+ return await response.json
1392
+ } else {
1393
+ throw new Error("Failed to create annotation")
1394
+ }
1395
+ }
1396
+
1397
+ async update(id, data) {
1398
+ const request = new FetchRequest("patch", this.getUpdateUrl(id), {
1399
+ body: JSON.stringify({ annotation: data }),
1400
+ contentType: "application/json",
1401
+ responseKind: "json"
1402
+ });
1403
+
1404
+ const response = await request.perform();
1405
+
1406
+ if (response.ok) {
1407
+ return await response.json
1408
+ } else {
1409
+ throw new Error("Failed to update annotation")
1410
+ }
1411
+ }
1412
+
1413
+ async delete(id) {
1414
+ const request = new FetchRequest("delete", this.getDeleteUrl(id), {
1415
+ responseKind: "json"
1416
+ });
1417
+
1418
+ const response = await request.perform();
1419
+
1420
+ if (response.ok) {
1421
+ return await response.json
1422
+ } else {
1423
+ throw new Error("Failed to delete annotation")
1424
+ }
1425
+ }
1426
+
1427
+ async restore(id) {
1428
+ const request = new FetchRequest("patch", this.getRestoreUrl(id), {
1429
+ responseKind: "json"
1430
+ });
1431
+
1432
+ const response = await request.perform();
1433
+
1434
+ if (response.ok) {
1435
+ return await response.json
1436
+ } else {
1437
+ throw new Error("Failed to restore annotation")
1438
+ }
1439
+ }
1440
+ }
1441
+
1442
+ /**
1443
+ * In-memory annotation store for development and demo purposes.
1444
+ *
1445
+ * Annotations are stored in memory only and lost on page refresh.
1446
+ * Useful for:
1447
+ * - Local development without a backend
1448
+ * - Demo/preview modes
1449
+ * - Testing
1450
+ *
1451
+ * @example
1452
+ * new MemoryAnnotationStore()
1453
+ */
1454
+ class MemoryAnnotationStore extends AnnotationStore {
1455
+ constructor() {
1456
+ super();
1457
+ this._annotations = [];
1458
+ this._nextId = 1;
1459
+ }
1460
+
1461
+ async load() {
1462
+ return [...this._annotations]
1463
+ }
1464
+
1465
+ async create(data) {
1466
+ const annotation = {
1467
+ ...data,
1468
+ id: `local-${this._nextId++}`,
1469
+ created_at: new Date().toISOString(),
1470
+ updated_at: new Date().toISOString()
1471
+ };
1472
+
1473
+ this._annotations.push(annotation);
1474
+ return annotation
1475
+ }
1476
+
1477
+ async update(id, data) {
1478
+ const index = this._annotations.findIndex(a => a.id === id);
1479
+ if (index === -1) {
1480
+ throw new Error("Annotation not found")
1481
+ }
1482
+
1483
+ const annotation = {
1484
+ ...this._annotations[index],
1485
+ ...data,
1486
+ id, // Preserve original id
1487
+ updated_at: new Date().toISOString()
1488
+ };
1489
+
1490
+ this._annotations[index] = annotation;
1491
+ return annotation
1492
+ }
1493
+
1494
+ async delete(id) {
1495
+ const index = this._annotations.findIndex(a => a.id === id);
1496
+ if (index === -1) {
1497
+ throw new Error("Annotation not found")
1498
+ }
1499
+
1500
+ const [annotation] = this._annotations.splice(index, 1);
1501
+ return annotation
1502
+ }
1503
+
1504
+ async restore(id) {
1505
+ // Memory store doesn't support soft-delete/restore
1506
+ console.warn("MemoryAnnotationStore.restore() is not supported");
1507
+ return null
1508
+ }
1509
+ }
1510
+
1253
1511
  // Custom event types for error handling
1254
1512
  const AnnotationErrorType = {
1255
1513
  LOAD_FAILED: "load_failed",
@@ -1260,13 +1518,31 @@ const AnnotationErrorType = {
1260
1518
  };
1261
1519
 
1262
1520
  class AnnotationManager {
1521
+ /**
1522
+ * @param {Object} options
1523
+ * @param {AnnotationStore} [options.store] - Custom store implementation
1524
+ * @param {string} [options.annotationsUrl] - Base URL for REST store (creates RestAnnotationStore)
1525
+ * @param {number} [options.documentId] - Document ID
1526
+ * @param {Function} [options.onAnnotationCreated] - Callback when annotation created
1527
+ * @param {Function} [options.onAnnotationUpdated] - Callback when annotation updated
1528
+ * @param {Function} [options.onAnnotationDeleted] - Callback when annotation deleted
1529
+ * @param {Element} [options.eventTarget] - Element for dispatching error events
1530
+ */
1263
1531
  constructor(options = {}) {
1264
- this.annotationsUrl = options.annotationsUrl;
1265
1532
  this.documentId = options.documentId;
1266
1533
  this.onAnnotationCreated = options.onAnnotationCreated;
1267
1534
  this.onAnnotationUpdated = options.onAnnotationUpdated;
1268
1535
  this.onAnnotationDeleted = options.onAnnotationDeleted;
1269
- this.eventTarget = options.eventTarget; // Optional element for dispatching events
1536
+ this.eventTarget = options.eventTarget;
1537
+
1538
+ // Determine store: explicit > REST URL > memory
1539
+ if (options.store) {
1540
+ this.store = options.store;
1541
+ } else if (options.annotationsUrl) {
1542
+ this.store = new RestAnnotationStore({ baseUrl: options.annotationsUrl });
1543
+ } else {
1544
+ this.store = new MemoryAnnotationStore();
1545
+ }
1270
1546
 
1271
1547
  this.annotations = new Map(); // id -> annotation
1272
1548
  this.annotationsByPage = new Map(); // pageNumber -> [annotations]
@@ -1291,15 +1567,8 @@ class AnnotationManager {
1291
1567
 
1292
1568
  async loadAnnotations() {
1293
1569
  try {
1294
- const request = new FetchRequest("get", `${this.annotationsUrl}.json`);
1295
- const response = await request.perform();
1296
-
1297
- if (response.ok) {
1298
- const data = await response.json;
1299
- this._processAnnotations(data);
1300
- } else {
1301
- throw new Error("Server returned an error")
1302
- }
1570
+ const annotations = await this.store.load();
1571
+ this._processAnnotations(annotations);
1303
1572
  } catch (error) {
1304
1573
  console.error("Failed to load annotations:", error);
1305
1574
  this._dispatchError(AnnotationErrorType.LOAD_FAILED, "Failed to load annotations", error);
@@ -1335,26 +1604,14 @@ class AnnotationManager {
1335
1604
 
1336
1605
  async createAnnotation(data) {
1337
1606
  try {
1338
- const request = new FetchRequest("post", this.annotationsUrl, {
1339
- body: JSON.stringify({ annotation: data }),
1340
- contentType: "application/json",
1341
- responseKind: "json"
1342
- });
1343
-
1344
- const response = await request.perform();
1345
-
1346
- if (response.ok) {
1347
- const annotation = await response.json;
1348
- this._addAnnotation(annotation);
1349
-
1350
- if (this.onAnnotationCreated) {
1351
- this.onAnnotationCreated(annotation);
1352
- }
1607
+ const annotation = await this.store.create(data);
1608
+ this._addAnnotation(annotation);
1353
1609
 
1354
- return annotation
1355
- } else {
1356
- throw new Error("Failed to create annotation")
1610
+ if (this.onAnnotationCreated) {
1611
+ this.onAnnotationCreated(annotation);
1357
1612
  }
1613
+
1614
+ return annotation
1358
1615
  } catch (error) {
1359
1616
  console.error("Failed to create annotation:", error);
1360
1617
  this._dispatchError(AnnotationErrorType.CREATE_FAILED, "Failed to save annotation", error);
@@ -1364,26 +1621,14 @@ class AnnotationManager {
1364
1621
 
1365
1622
  async updateAnnotation(id, data) {
1366
1623
  try {
1367
- const request = new FetchRequest("patch", `${this.annotationsUrl}/${id}`, {
1368
- body: JSON.stringify({ annotation: data }),
1369
- contentType: "application/json",
1370
- responseKind: "json"
1371
- });
1624
+ const annotation = await this.store.update(id, data);
1625
+ this._updateAnnotation(annotation);
1372
1626
 
1373
- const response = await request.perform();
1374
-
1375
- if (response.ok) {
1376
- const annotation = await response.json;
1377
- this._updateAnnotation(annotation);
1378
-
1379
- if (this.onAnnotationUpdated) {
1380
- this.onAnnotationUpdated(annotation);
1381
- }
1382
-
1383
- return annotation
1384
- } else {
1385
- throw new Error("Failed to update annotation")
1627
+ if (this.onAnnotationUpdated) {
1628
+ this.onAnnotationUpdated(annotation);
1386
1629
  }
1630
+
1631
+ return annotation
1387
1632
  } catch (error) {
1388
1633
  console.error("Failed to update annotation:", error);
1389
1634
  this._dispatchError(AnnotationErrorType.UPDATE_FAILED, "Failed to update annotation", error);
@@ -1392,26 +1637,18 @@ class AnnotationManager {
1392
1637
  }
1393
1638
 
1394
1639
  async deleteAnnotation(id) {
1395
- try {
1396
- const annotation = this.annotations.get(id);
1397
- if (!annotation) return
1640
+ const existingAnnotation = this.annotations.get(id);
1641
+ if (!existingAnnotation) return
1398
1642
 
1399
- const request = new FetchRequest("delete", `${this.annotationsUrl}/${id}`, {
1400
- responseKind: "json"
1401
- });
1402
- const response = await request.perform();
1403
-
1404
- if (response.ok) {
1405
- this._removeAnnotation(id);
1406
-
1407
- if (this.onAnnotationDeleted) {
1408
- this.onAnnotationDeleted(annotation);
1409
- }
1643
+ try {
1644
+ const annotation = await this.store.delete(id);
1645
+ this._removeAnnotation(id);
1410
1646
 
1411
- return annotation
1412
- } else {
1413
- throw new Error("Failed to delete annotation")
1647
+ if (this.onAnnotationDeleted) {
1648
+ this.onAnnotationDeleted(existingAnnotation);
1414
1649
  }
1650
+
1651
+ return existingAnnotation
1415
1652
  } catch (error) {
1416
1653
  console.error("Failed to delete annotation:", error);
1417
1654
  this._dispatchError(AnnotationErrorType.DELETE_FAILED, "Failed to delete annotation", error);
@@ -1421,23 +1658,16 @@ class AnnotationManager {
1421
1658
 
1422
1659
  async restoreAnnotation(id) {
1423
1660
  try {
1424
- const request = new FetchRequest("patch", `${this.annotationsUrl}/${id}/restore`, {
1425
- responseKind: "json"
1426
- });
1427
- const response = await request.perform();
1428
-
1429
- if (response.ok) {
1430
- const annotation = await response.json;
1431
- this._addAnnotation(annotation);
1661
+ const annotation = await this.store.restore(id);
1662
+ if (!annotation) return null
1432
1663
 
1433
- if (this.onAnnotationCreated) {
1434
- this.onAnnotationCreated(annotation);
1435
- }
1664
+ this._addAnnotation(annotation);
1436
1665
 
1437
- return annotation
1438
- } else {
1439
- throw new Error("Failed to restore annotation")
1666
+ if (this.onAnnotationCreated) {
1667
+ this.onAnnotationCreated(annotation);
1440
1668
  }
1669
+
1670
+ return annotation
1441
1671
  } catch (error) {
1442
1672
  console.error("Failed to restore annotation:", error);
1443
1673
  this._dispatchError(AnnotationErrorType.RESTORE_FAILED, "Failed to restore annotation", error);
@@ -2110,6 +2340,11 @@ const Icons = {
2110
2340
  <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
2111
2341
  </svg>`,
2112
2342
 
2343
+ // Comment/Speech bubble icon - used in annotation edit toolbar
2344
+ comment: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2345
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
2346
+ </svg>`,
2347
+
2113
2348
  // Chevron down - used in color pickers, dropdowns
2114
2349
  chevronDown: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
2115
2350
  <polyline points="6 9 12 15 18 9"/>
@@ -2244,6 +2479,7 @@ class AnnotationEditToolbar {
2244
2479
  this.onColorChange = options.onColorChange;
2245
2480
  this.onDelete = options.onDelete;
2246
2481
  this.onEdit = options.onEdit;
2482
+ this.onComment = options.onComment;
2247
2483
  this.onDeselect = options.onDeselect;
2248
2484
  this.colors = options.colors || ColorPicker.COLORS.map(c => c.value);
2249
2485
 
@@ -2260,6 +2496,9 @@ class AnnotationEditToolbar {
2260
2496
  this.element.className = "annotation-edit-toolbar hidden";
2261
2497
  this.element.innerHTML = `
2262
2498
  <div class="toolbar-buttons">
2499
+ <button class="toolbar-btn comment-btn hidden" title="Add Comment (C)">
2500
+ ${Icons.comment}
2501
+ </button>
2263
2502
  <button class="color-picker-btn" title="Change color" aria-haspopup="true" aria-expanded="false">
2264
2503
  <span class="color-swatch"></span>
2265
2504
  ${Icons.chevronDown}
@@ -2279,11 +2518,12 @@ class AnnotationEditToolbar {
2279
2518
  ${Icons.delete}
2280
2519
  </button>
2281
2520
  </div>
2282
- <div class="toolbar-note-content hidden"></div>
2521
+ <div class="toolbar-annotation-content hidden"></div>
2283
2522
  `;
2284
2523
 
2524
+ this.commentBtn = this.element.querySelector(".comment-btn");
2285
2525
  this.editBtn = this.element.querySelector(".edit-btn");
2286
- this.noteContent = this.element.querySelector(".toolbar-note-content");
2526
+ this.annotationContent = this.element.querySelector(".toolbar-annotation-content");
2287
2527
  }
2288
2528
 
2289
2529
  _setupEventListeners() {
@@ -2304,7 +2544,15 @@ class AnnotationEditToolbar {
2304
2544
  });
2305
2545
  });
2306
2546
 
2307
- // Edit button
2547
+ // Comment button (for highlight/underline/ink annotations)
2548
+ this.commentBtn.addEventListener("click", (e) => {
2549
+ e.stopPropagation();
2550
+ if (this.currentAnnotation && this.onComment) {
2551
+ this.onComment(this.currentAnnotation);
2552
+ }
2553
+ });
2554
+
2555
+ // Edit button (for notes)
2308
2556
  this.editBtn.addEventListener("click", (e) => {
2309
2557
  e.stopPropagation();
2310
2558
  if (this.currentAnnotation && this.onEdit) {
@@ -2357,6 +2605,13 @@ class AnnotationEditToolbar {
2357
2605
  e.preventDefault();
2358
2606
  this.onEdit(this.currentAnnotation);
2359
2607
  }
2608
+ } else if (e.key === "c" || e.key === "C") {
2609
+ // Comment shortcut for highlight/underline/ink annotations
2610
+ const supportsComment = ["highlight", "line", "ink"].includes(this.currentAnnotation?.annotation_type);
2611
+ if (supportsComment && this.onComment) {
2612
+ e.preventDefault();
2613
+ this.onComment(this.currentAnnotation);
2614
+ }
2360
2615
  }
2361
2616
  });
2362
2617
  }
@@ -2412,15 +2667,26 @@ class AnnotationEditToolbar {
2412
2667
  const color = annotation.color || ColorPicker.DEFAULT_HIGHLIGHT_COLOR;
2413
2668
  this._updateSelectedColor(color);
2414
2669
 
2415
- // Show/hide edit button and note content based on annotation type
2670
+ // Show/hide buttons based on annotation type
2416
2671
  const isNote = annotation.annotation_type === "note";
2672
+ const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
2673
+
2674
+ // Comment button for highlight/underline/ink, edit button for notes
2675
+ this.commentBtn.classList.toggle("hidden", !supportsComment);
2417
2676
  this.editBtn.classList.toggle("hidden", !isNote);
2418
2677
 
2419
- if (isNote && annotation.contents) {
2420
- this.noteContent.textContent = annotation.contents;
2421
- this.noteContent.classList.remove("hidden");
2678
+ // Update comment button title based on whether contents exists
2679
+ if (supportsComment) {
2680
+ const hasComment = annotation.contents && annotation.contents.trim();
2681
+ this.commentBtn.title = hasComment ? "Edit Comment (C)" : "Add Comment (C)";
2682
+ }
2683
+
2684
+ // Show contents for any annotation type that has it
2685
+ if (annotation.contents) {
2686
+ this.annotationContent.textContent = annotation.contents;
2687
+ this.annotationContent.classList.remove("hidden");
2422
2688
  } else {
2423
- this.noteContent.classList.add("hidden");
2689
+ this.annotationContent.classList.add("hidden");
2424
2690
  }
2425
2691
 
2426
2692
  // Determine if toolbar should flip above the annotation
@@ -2441,9 +2707,9 @@ class AnnotationEditToolbar {
2441
2707
  this.element.classList.add("hidden");
2442
2708
  this.currentAnnotation = null;
2443
2709
 
2444
- // Clear note content
2445
- this.noteContent.textContent = "";
2446
- this.noteContent.classList.add("hidden");
2710
+ // Clear annotation content
2711
+ this.annotationContent.textContent = "";
2712
+ this.annotationContent.classList.add("hidden");
2447
2713
 
2448
2714
  // Remove from parent when hidden
2449
2715
  if (this.element.parentNode) {
@@ -3137,10 +3403,10 @@ const ANNOTATION_ICONS = {
3137
3403
  };
3138
3404
 
3139
3405
  class AnnotationSidebar {
3140
- constructor({ container, annotationManager, onAnnotationClick }) {
3141
- this.container = container;
3406
+ constructor({ element, itemTemplate, container, annotationManager, onAnnotationClick }) {
3142
3407
  this.annotationManager = annotationManager;
3143
3408
  this.onAnnotationClick = onAnnotationClick;
3409
+ this.itemTemplate = itemTemplate; // Optional <template> element for custom list items
3144
3410
 
3145
3411
  this.isOpen = false;
3146
3412
  this.sidebarWidth = SIDEBAR_DEFAULT_WIDTH;
@@ -3148,7 +3414,30 @@ class AnnotationSidebar {
3148
3414
  this.filterType = FilterType.ALL;
3149
3415
  this.selectedAnnotationId = null;
3150
3416
 
3151
- this._createElements();
3417
+ if (element) {
3418
+ // User provided HTML - find elements via data attributes
3419
+ this.element = element;
3420
+ this.container = element.parentElement;
3421
+ this.listContainer = element.querySelector('[data-role="list"]');
3422
+ this.header = element.querySelector('.pdf-sidebar-header');
3423
+ this.emptyState = element.querySelector('[data-role="empty-state"]');
3424
+ this.sortControls = element.querySelector('[data-role="sort-controls"]');
3425
+ this.filterControls = element.querySelector('[data-role="filter-controls"]');
3426
+ this.resizer = element.querySelector('[data-role="resizer"]');
3427
+
3428
+ // Read initial width from CSS variable if set
3429
+ const currentWidth = element.style.getPropertyValue('--sidebar-width');
3430
+ if (currentWidth) {
3431
+ this.sidebarWidth = parseInt(currentWidth, 10) || SIDEBAR_DEFAULT_WIDTH;
3432
+ } else {
3433
+ element.style.setProperty('--sidebar-width', `${this.sidebarWidth}px`);
3434
+ }
3435
+ } else {
3436
+ // Fallback - create default HTML (existing behavior)
3437
+ this.container = container;
3438
+ this._createElements();
3439
+ }
3440
+
3152
3441
  this._setupEventListeners();
3153
3442
  }
3154
3443
 
@@ -3231,27 +3520,35 @@ class AnnotationSidebar {
3231
3520
  }
3232
3521
 
3233
3522
  _setupEventListeners() {
3234
- // Close button
3235
- const closeBtn = this.header.querySelector(".pdf-sidebar-close");
3236
- closeBtn.addEventListener("click", () => this.close());
3523
+ // Close button - support both user HTML (data-action="close") and auto-generated (.pdf-sidebar-close)
3524
+ const closeBtn = this.header?.querySelector('[data-action="close"]') ||
3525
+ this.header?.querySelector(".pdf-sidebar-close");
3526
+ if (closeBtn) {
3527
+ closeBtn.addEventListener("click", () => this.close());
3528
+ }
3237
3529
 
3238
3530
  // Sort buttons
3239
- this.sortControls.addEventListener("click", (e) => {
3240
- const btn = e.target.closest(".sort-btn");
3241
- if (btn) {
3242
- this.sortMode = btn.dataset.sort;
3243
- this.sortControls.querySelectorAll(".sort-btn").forEach(b => b.classList.remove("active"));
3244
- btn.classList.add("active");
3245
- this._refreshList();
3246
- }
3247
- });
3531
+ if (this.sortControls) {
3532
+ this.sortControls.addEventListener("click", (e) => {
3533
+ const btn = e.target.closest("[data-sort]") || e.target.closest(".sort-btn");
3534
+ if (btn && btn.dataset.sort) {
3535
+ this.sortMode = btn.dataset.sort;
3536
+ this.sortControls.querySelectorAll("[data-sort], .sort-btn").forEach(b => b.classList.remove("active"));
3537
+ btn.classList.add("active");
3538
+ this._refreshList();
3539
+ }
3540
+ });
3541
+ }
3248
3542
 
3249
- // Filter select
3250
- const filterSelect = this.filterControls.querySelector(".annotation-filter-select");
3251
- filterSelect.addEventListener("change", (e) => {
3252
- this.filterType = e.target.value;
3253
- this._refreshList();
3254
- });
3543
+ // Filter select - support both user HTML (data-action="filter") and auto-generated (.annotation-filter-select)
3544
+ const filterSelect = this.filterControls?.querySelector('[data-action="filter"]') ||
3545
+ this.filterControls?.querySelector(".annotation-filter-select");
3546
+ if (filterSelect) {
3547
+ filterSelect.addEventListener("change", (e) => {
3548
+ this.filterType = e.target.value;
3549
+ this._refreshList();
3550
+ });
3551
+ }
3255
3552
 
3256
3553
  // Sidebar resizing
3257
3554
  this._setupResizer();
@@ -3263,6 +3560,8 @@ class AnnotationSidebar {
3263
3560
  }
3264
3561
 
3265
3562
  _setupResizer() {
3563
+ if (!this.resizer) return
3564
+
3266
3565
  let startX, startWidth;
3267
3566
 
3268
3567
  const onMouseMove = (e) => {
@@ -3346,16 +3645,19 @@ class AnnotationSidebar {
3346
3645
  // Clear and rebuild list
3347
3646
  this.listContainer.innerHTML = "";
3348
3647
 
3349
- // Update count badge
3350
- const countBadge = this.header.querySelector(".annotation-count-badge");
3351
- countBadge.textContent = annotations.length;
3648
+ // Update count badge - support both user HTML (data-role="count") and auto-generated
3649
+ const countBadge = this.header?.querySelector('[data-role="count"]') ||
3650
+ this.header?.querySelector(".annotation-count-badge");
3651
+ if (countBadge) {
3652
+ countBadge.textContent = annotations.length;
3653
+ }
3352
3654
 
3353
3655
  // Show empty state or list
3354
3656
  if (annotations.length === 0) {
3355
- this.emptyState.classList.add("visible");
3657
+ this.emptyState?.classList.add("visible");
3356
3658
  this.listContainer.classList.add("empty");
3357
3659
  } else {
3358
- this.emptyState.classList.remove("visible");
3660
+ this.emptyState?.classList.remove("visible");
3359
3661
  this.listContainer.classList.remove("empty");
3360
3662
 
3361
3663
  for (const annotation of annotations) {
@@ -3424,40 +3726,71 @@ class AnnotationSidebar {
3424
3726
  }
3425
3727
 
3426
3728
  _createListItem(annotation) {
3427
- const item = document.createElement("div");
3428
- item.className = "annotation-list-item";
3429
- item.dataset.annotationId = annotation.id;
3430
- item.tabIndex = 0;
3729
+ let item;
3431
3730
 
3432
- if (this.selectedAnnotationId === annotation.id) {
3433
- item.classList.add("selected");
3434
- }
3731
+ if (this.itemTemplate) {
3732
+ // Clone user's template and populate data-field elements
3733
+ item = this.itemTemplate.content.firstElementChild.cloneNode(true);
3734
+ item.dataset.annotationId = annotation.id;
3435
3735
 
3436
- // Determine icon and label based on type
3437
- const { icon, label, typeLabel } = this._getAnnotationDisplay(annotation);
3736
+ // Ensure tabIndex for keyboard navigation
3737
+ if (!item.hasAttribute("tabindex")) {
3738
+ item.tabIndex = 0;
3739
+ }
3438
3740
 
3439
- // Format timestamp
3440
- const timestamp = this._formatTimestamp(annotation.created_at);
3741
+ // Determine display values
3742
+ const { icon, label, typeLabel } = this._getAnnotationDisplay(annotation);
3743
+ const timestamp = this._formatTimestamp(annotation.created_at);
3744
+
3745
+ // Populate data-field elements
3746
+ this._setField(item, "icon", icon, annotation.color);
3747
+ this._setField(item, "label", this._escapeHtml(label));
3748
+ this._setField(item, "type", typeLabel);
3749
+ this._setField(item, "page", `Page ${annotation.page}`);
3750
+ this._setField(item, "time", timestamp);
3751
+
3752
+ // Also set data attributes for user's Stimulus controllers
3753
+ item.dataset.annotationType = annotation.annotation_type;
3754
+ item.dataset.annotationPage = annotation.page;
3755
+ item.dataset.annotationColor = annotation.color || "";
3756
+ } else {
3757
+ // Fallback - existing innerHTML approach
3758
+ item = document.createElement("div");
3759
+ item.className = "annotation-list-item";
3760
+ item.dataset.annotationId = annotation.id;
3761
+ item.tabIndex = 0;
3441
3762
 
3442
- item.innerHTML = `
3443
- <div class="annotation-item-icon" style="color: ${annotation.color || '#666'}">
3444
- ${icon}
3445
- </div>
3446
- <div class="annotation-item-content">
3447
- <div class="annotation-item-label">${this._escapeHtml(label)}</div>
3448
- <div class="annotation-item-meta">
3449
- <span class="annotation-item-type">${typeLabel}</span>
3450
- <span class="annotation-item-separator">•</span>
3451
- <span class="annotation-item-page">Page ${annotation.page}</span>
3452
- <span class="annotation-item-separator">•</span>
3453
- <span class="annotation-item-time">${timestamp}</span>
3763
+ // Determine icon and label based on type
3764
+ const { icon, label, typeLabel } = this._getAnnotationDisplay(annotation);
3765
+
3766
+ // Format timestamp
3767
+ const timestamp = this._formatTimestamp(annotation.created_at);
3768
+
3769
+ item.innerHTML = `
3770
+ <div class="annotation-item-icon" style="color: ${annotation.color || '#666'}">
3771
+ ${icon}
3454
3772
  </div>
3455
- </div>
3456
- <div class="annotation-item-hover">
3457
- <span>Jump</span>
3458
- ${Icons.chevronRight}
3459
- </div>
3460
- `;
3773
+ <div class="annotation-item-content">
3774
+ <div class="annotation-item-label">${this._escapeHtml(label)}</div>
3775
+ <div class="annotation-item-meta">
3776
+ <span class="annotation-item-type">${typeLabel}</span>
3777
+ <span class="annotation-item-separator">•</span>
3778
+ <span class="annotation-item-page">Page ${annotation.page}</span>
3779
+ <span class="annotation-item-separator">•</span>
3780
+ <span class="annotation-item-time">${timestamp}</span>
3781
+ </div>
3782
+ </div>
3783
+ <div class="annotation-item-hover">
3784
+ <span>Jump</span>
3785
+ ${Icons.chevronRight}
3786
+ </div>
3787
+ `;
3788
+ }
3789
+
3790
+ // Selection state
3791
+ if (this.selectedAnnotationId === annotation.id) {
3792
+ item.classList.add("selected");
3793
+ }
3461
3794
 
3462
3795
  // Click handler
3463
3796
  item.addEventListener("click", () => {
@@ -3470,6 +3803,23 @@ class AnnotationSidebar {
3470
3803
  return item
3471
3804
  }
3472
3805
 
3806
+ /**
3807
+ * Set a field value in a template-cloned element
3808
+ * @param {HTMLElement} element - The cloned template element
3809
+ * @param {string} fieldName - The data-field name to find
3810
+ * @param {string} value - The value to set (can include HTML for icons)
3811
+ * @param {string} color - Optional color to apply
3812
+ */
3813
+ _setField(element, fieldName, value, color) {
3814
+ const field = element.querySelector(`[data-field="${fieldName}"]`);
3815
+ if (field) {
3816
+ field.innerHTML = value;
3817
+ if (color && fieldName === "icon") {
3818
+ field.style.color = color;
3819
+ }
3820
+ }
3821
+ }
3822
+
3473
3823
  _getAnnotationDisplay(annotation) {
3474
3824
  const type = annotation.annotation_type;
3475
3825
  let icon, label, typeLabel;
@@ -4844,15 +5194,25 @@ class CoordinateTransformer {
4844
5194
  for (let i = 0; i < result.length; i++) {
4845
5195
  const existing = result[i];
4846
5196
 
4847
- // Check if rects overlap (share vertical AND horizontal space)
4848
- const verticalOverlap = rect.top < existing.bottom && rect.bottom > existing.top;
5197
+ // Calculate vertical overlap amount
5198
+ const overlapTop = Math.max(rect.top, existing.top);
5199
+ const overlapBottom = Math.min(rect.bottom, existing.bottom);
5200
+ const overlapHeight = Math.max(0, overlapBottom - overlapTop);
5201
+
5202
+ // Require significant vertical overlap (at least 50% of smaller rect's height)
5203
+ // This prevents merging rects from different lines that only slightly overlap
5204
+ const rectHeight = rect.bottom - rect.top;
5205
+ const existingHeight = existing.bottom - existing.top;
5206
+ const minHeight = Math.min(rectHeight, existingHeight);
5207
+ const significantVerticalOverlap = overlapHeight > minHeight * 0.5;
5208
+
4849
5209
  const horizontalOverlap = rect.left < existing.right && rect.right > existing.left;
4850
5210
 
4851
5211
  // Also merge if they're adjacent horizontally on same line
4852
5212
  const sameLine = Math.abs(rect.top - existing.top) < 3 && Math.abs(rect.bottom - existing.bottom) < 3;
4853
5213
  const horizontallyAdjacent = Math.abs(rect.left - existing.right) < 2 || Math.abs(existing.left - rect.right) < 2;
4854
5214
 
4855
- if ((verticalOverlap && horizontalOverlap) || (sameLine && horizontallyAdjacent)) {
5215
+ if ((significantVerticalOverlap && horizontalOverlap) || (sameLine && horizontallyAdjacent)) {
4856
5216
  // Merge: extend existing rect to encompass both
4857
5217
  result[i] = {
4858
5218
  left: Math.min(existing.left, rect.left),
@@ -5817,12 +6177,12 @@ class NoteTool extends BaseTool {
5817
6177
  });
5818
6178
  }
5819
6179
 
5820
- // Method to edit an existing note
6180
+ // Method to edit an existing note or add/edit a comment on other annotation types
5821
6181
  editNote(annotation) {
5822
6182
  // Store currently focused element for restoration on close
5823
6183
  this._previousFocusElement = document.activeElement;
5824
6184
 
5825
- // Get the position of the note on screen
6185
+ // Get the position of the annotation on screen
5826
6186
  const pageContainer = this.viewer.getPageContainer(annotation.page);
5827
6187
  if (!pageContainer) return
5828
6188
 
@@ -5834,10 +6194,20 @@ class NoteTool extends BaseTool {
5834
6194
  // Store the annotation being edited
5835
6195
  this.editingAnnotation = annotation;
5836
6196
 
5837
- this._showEditDialog(rect.left + x, rect.top + y, annotation.contents);
6197
+ // Determine dialog title based on annotation type and whether contents exists
6198
+ const isNote = annotation.annotation_type === "note";
6199
+ const hasContents = annotation.contents && annotation.contents.trim();
6200
+ let dialogTitle;
6201
+ if (isNote) {
6202
+ dialogTitle = "Edit Note";
6203
+ } else {
6204
+ dialogTitle = hasContents ? "Edit Comment" : "Add Comment";
6205
+ }
6206
+
6207
+ this._showEditDialog(rect.left + x, rect.top + y, annotation.contents, dialogTitle);
5838
6208
  }
5839
6209
 
5840
- _showEditDialog(x, y, existingText) {
6210
+ _showEditDialog(x, y, existingText, title = "Edit Note") {
5841
6211
  // Remove any existing dialog (but keep editingAnnotation)
5842
6212
  this._removeDialog();
5843
6213
 
@@ -5846,7 +6216,7 @@ class NoteTool extends BaseTool {
5846
6216
  this.noteDialog.className = "note-dialog";
5847
6217
  this.noteDialog.innerHTML = `
5848
6218
  <div class="note-dialog-header">
5849
- <span>Edit Note</span>
6219
+ <span>${title}</span>
5850
6220
  <button class="note-dialog-close" aria-label="Close">
5851
6221
  ${Icons.close}
5852
6222
  </button>
@@ -6328,7 +6698,9 @@ class PdfViewer {
6328
6698
  this._setupViewerEvents();
6329
6699
 
6330
6700
  // Annotation manager for CRUD operations
6701
+ // Accepts custom store, falls back to REST store if URL provided, else memory store
6331
6702
  this.annotationManager = new AnnotationManager({
6703
+ store: this.options.annotationStore,
6332
6704
  annotationsUrl: this.annotationsUrl,
6333
6705
  documentId: this.documentId,
6334
6706
  eventTarget: this.container, // For dispatching error events
@@ -6354,6 +6726,7 @@ class PdfViewer {
6354
6726
  onColorChange: this._onAnnotationColorChange.bind(this),
6355
6727
  onDelete: this._onAnnotationDelete.bind(this),
6356
6728
  onEdit: this._onAnnotationEdit.bind(this),
6729
+ onComment: this._onAnnotationComment.bind(this),
6357
6730
  onDeselect: this._deselectAnnotation.bind(this)
6358
6731
  });
6359
6732
 
@@ -6374,9 +6747,14 @@ class PdfViewer {
6374
6747
  onPageClick: (pageNumber) => this.viewer.goToPage(pageNumber)
6375
6748
  });
6376
6749
 
6377
- // Annotation sidebar (inserted after pages container in the body)
6750
+ // Annotation sidebar - check for user-defined element, fallback to auto-generated
6751
+ const annotationSidebarEl = this.container.querySelector('[data-pdf-sidebar="annotations"]');
6752
+ const annotationItemTemplate = this.container.querySelector('[data-pdf-template="annotation-item"]');
6753
+
6378
6754
  this.annotationSidebar = new AnnotationSidebar({
6379
- container: this.bodyContainer,
6755
+ element: annotationSidebarEl, // null if not provided (triggers fallback)
6756
+ itemTemplate: annotationItemTemplate, // null if not provided (uses innerHTML)
6757
+ container: this.bodyContainer, // Used for fallback
6380
6758
  annotationManager: this.annotationManager,
6381
6759
  onAnnotationClick: (annotationId) => this._scrollToAnnotationWithFlash(annotationId)
6382
6760
  });
@@ -6538,7 +6916,7 @@ class PdfViewer {
6538
6916
  await this.thumbnailSidebar.setDocument(this.viewer.pdfDocument);
6539
6917
  }
6540
6918
 
6541
- // Load existing annotations
6919
+ // Load existing annotations from store
6542
6920
  await this.annotationManager.loadAnnotations();
6543
6921
 
6544
6922
  // Render annotations on all rendered pages
@@ -6757,6 +7135,14 @@ class PdfViewer {
6757
7135
  }
6758
7136
  }
6759
7137
 
7138
+ _onAnnotationComment(annotation) {
7139
+ // For highlight/underline/ink, use the note tool's edit dialog to edit contents
7140
+ const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
7141
+ if (supportsComment) {
7142
+ this.tools[ToolMode.NOTE].editNote(annotation);
7143
+ }
7144
+ }
7145
+
6760
7146
  async _onAnnotationDelete(annotation) {
6761
7147
  await this.annotationManager.deleteAnnotation(annotation.id);
6762
7148
  }
@@ -7570,7 +7956,8 @@ class pdf_viewer_controller extends Controller {
7570
7956
  documentId: String,
7571
7957
  trackingUrl: String,
7572
7958
  initialPage: Number,
7573
- initialAnnotation: String
7959
+ initialAnnotation: String,
7960
+ autoHeight: { type: Boolean, default: true }
7574
7961
  }
7575
7962
 
7576
7963
  initialize() {
@@ -8036,8 +8423,8 @@ class pdf_viewer_controller extends Controller {
8036
8423
 
8037
8424
  setViewportHeight() {
8038
8425
  requestAnimationFrame(() => {
8039
- // Skip if using CSS flexbox layout (document-fullscreen wrapper handles sizing)
8040
- if (this.containerTarget.closest('.document-fullscreen')) {
8426
+ // Skip if autoHeight is disabled (container height managed by consuming application)
8427
+ if (!this.autoHeightValue) {
8041
8428
  return
8042
8429
  }
8043
8430
 
@@ -8124,40 +8511,5 @@ class pdf_download_controller extends Controller {
8124
8511
  }
8125
8512
  }
8126
8513
 
8127
- class pdf_sync_scroll_controller extends Controller {
8128
- static targets = ["container", "toggle"]
8129
-
8130
- connect() {
8131
- this.isSyncing = false;
8132
- }
8133
-
8134
- sync(event) {
8135
- if (this.isSyncing) return
8136
- if (!this.toggleTarget.checked) return
8137
-
8138
- const master = event.currentTarget;
8139
- const slave = this.containerTargets.find(target => target !== master);
8140
-
8141
- if (!slave) return
8142
-
8143
- this.isSyncing = true;
8144
-
8145
- // Calculate percentage-based scroll to account for potential
8146
- // differences in zoom levels or page counts
8147
- const scrollPercentageY = master.scrollTop / (master.scrollHeight - master.clientHeight);
8148
- const scrollPercentageX = master.scrollLeft / (master.scrollWidth - master.clientWidth);
8149
-
8150
- window.requestAnimationFrame(() => {
8151
- slave.scrollTop = scrollPercentageY * (slave.scrollHeight - slave.clientHeight);
8152
- slave.scrollLeft = scrollPercentageX * (slave.scrollWidth - slave.clientWidth);
8153
-
8154
- // Reset the lock after the browser paint
8155
- window.requestAnimationFrame(() => {
8156
- this.isSyncing = false;
8157
- });
8158
- });
8159
- }
8160
- }
8161
-
8162
- export { CoreViewer, pdf_download_controller as PdfDownloadController, pdf_sync_scroll_controller as PdfSyncScrollController, PdfViewer, pdf_viewer_controller as PdfViewerController, ToolMode, ViewerEvents };
8514
+ export { AnnotationStore, CoreViewer, MemoryAnnotationStore, pdf_download_controller as PdfDownloadController, PdfViewer, pdf_viewer_controller as PdfViewerController, RestAnnotationStore, ToolMode, ViewerEvents };
8163
8515
  //# sourceMappingURL=stimulus-pdf-viewer.esm.js.map