]> BookStack Code Mirror - bookstack/commitdiff
Comments: Added inline comment marker/highlight logic
authorDan Brown <redacted>
Sat, 19 Apr 2025 13:07:52 +0000 (14:07 +0100)
committerDan Brown <redacted>
Sat, 19 Apr 2025 13:07:52 +0000 (14:07 +0100)
resources/js/components/page-comment.ts
resources/js/components/pointer.ts
resources/js/services/dom.ts
resources/js/services/util.ts
resources/sass/_pages.scss

index b2e2bac2784d9e3306636d3f13fce430bf1c54cf..f4d295b95a3d304c44d9193e8862ff38972211fc 100644 (file)
@@ -1,6 +1,7 @@
 import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
+import {findTargetNodeAndOffset, getLoading, hashElement, htmlToDom} from '../services/dom.ts';
 import {buildForInput} from '../wysiwyg-tinymce/config';
+import {el} from "../wysiwyg/utils/dom";
 
 export class PageComment extends Component {
 
@@ -46,6 +47,7 @@ export class PageComment extends Component {
         this.input = this.$refs.input as HTMLInputElement;
 
         this.setupListeners();
+        this.positionForReference();
     }
 
     protected setupListeners(): void {
@@ -135,4 +137,47 @@ export class PageComment extends Component {
         return loading;
     }
 
+    protected positionForReference() {
+        if (!this.commentContentRef) {
+            return;
+        }
+
+        const [refId, refHash, refRange] = this.commentContentRef.split(':');
+        const refEl = document.getElementById(refId);
+        if (!refEl) {
+            // TODO - Show outdated marker for comment
+            return;
+        }
+
+        const actualHash = hashElement(refEl);
+        if (actualHash !== refHash) {
+            // TODO - Show outdated marker for comment
+            return;
+        }
+
+        const refElBounds = refEl.getBoundingClientRect();
+        let bounds = refElBounds;
+        const [rangeStart, rangeEnd] = refRange.split('-');
+        if (rangeStart && rangeEnd) {
+            const range = new Range();
+            const relStart = findTargetNodeAndOffset(refEl, Number(rangeStart));
+            const relEnd = findTargetNodeAndOffset(refEl, Number(rangeEnd));
+            if (relStart && relEnd) {
+                range.setStart(relStart.node, relStart.offset);
+                range.setEnd(relEnd.node, relEnd.offset);
+                bounds = range.getBoundingClientRect();
+            }
+        }
+
+        const relLeft = bounds.left - refElBounds.left;
+        const relTop = bounds.top - refElBounds.top;
+        // TODO - Extract to class, Use theme color
+        const marker = el('div', {
+            class: 'content-comment-highlight',
+            style: `left: ${relLeft}px; top: ${relTop}px; width: ${bounds.width}px; height: ${bounds.height}px;`
+        }, ['']);
+
+        refEl.style.position = 'relative';
+        refEl.append(marker);
+    }
 }
index c3883b7b55411109484e9b689ff794f69449f02f..d84186d872d02ac317a74ed35fc96e1af669b392 100644 (file)
@@ -1,8 +1,7 @@
 import * as DOM from '../services/dom.ts';
 import {Component} from './component';
 import {copyTextToClipboard} from '../services/clipboard.ts';
-import {cyrb53} from "../services/util";
-import {normalizeNodeTextOffsetToParent} from "../services/dom.ts";
+import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom.ts";
 import {PageComments} from "./page-comments";
 
 export class Pointer extends Component {
@@ -183,9 +182,8 @@ export class Pointer extends Component {
             return;
         }
 
-        const normalisedElemHtml = this.targetElement.outerHTML.replace(/\s{2,}/g, '');
         const refId = this.targetElement.id;
-        const hash = cyrb53(normalisedElemHtml);
+        const hash = hashElement(this.targetElement);
         let range = '';
         if (this.targetSelectionRange) {
             const commonContainer = this.targetSelectionRange.commonAncestorContainer;
index 537af816a907ad6e7a98ed4c1643fdc91ce40f8f..661ed7ca3e5068a2907cae785299c592a4b86ba6 100644 (file)
@@ -1,3 +1,5 @@
+import {cyrb53} from "./util";
+
 /**
  * Check if the given param is a HTMLElement
  */
@@ -181,6 +183,9 @@ export function htmlToDom(html: string): HTMLElement {
     return firstChild;
 }
 
+/**
+ * For the given node and offset, return an adjusted offset that's relative to the given parent element.
+ */
 export function normalizeNodeTextOffsetToParent(node: Node, offset: number, parentElement: HTMLElement): number {
     if (!parentElement.contains(node)) {
         throw new Error('ParentElement must be a prent of element');
@@ -201,3 +206,54 @@ export function normalizeNodeTextOffsetToParent(node: Node, offset: number, pare
 
     return normalizedOffset;
 }
+
+/**
+ * Find the target child node and adjusted offset based on a parent node and text offset.
+ * Returns null if offset not found within the given parent node.
+ */
+export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number): ({node: Node, offset: number}|null) {
+    if (offset === 0) {
+        return { node: parentNode, offset: 0 };
+    }
+
+    let currentOffset = 0;
+    let currentNode = null;
+
+    for (let i = 0; i < parentNode.childNodes.length; i++) {
+        currentNode = parentNode.childNodes[i];
+
+        if (currentNode.nodeType === Node.TEXT_NODE) {
+            // For text nodes, count the length of their content
+            // Returns if within range
+            const textLength = currentNode.textContent.length;
+            if (currentOffset + textLength >= offset) {
+                return {
+                    node: currentNode,
+                    offset: offset - currentOffset
+                };
+            }
+
+            currentOffset += textLength;
+        } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
+            // Otherwise, if an element, track the text length and search within
+            // if in range for the target offset
+            const elementTextLength = currentNode.textContent.length;
+            if (currentOffset + elementTextLength >= offset) {
+                return findTargetNodeAndOffset(currentNode, offset - currentOffset);
+            }
+
+            currentOffset += elementTextLength;
+        }
+    }
+
+    // Return null if not found within range
+    return null;
+}
+
+/**
+ * Create a hash for the given HTML element.
+ */
+export function hashElement(element: HTMLElement): string {
+    const normalisedElemHtml = element.outerHTML.replace(/\s{2,}/g, '');
+    return cyrb53(normalisedElemHtml);
+}
\ No newline at end of file
index 1a6fa55b6b06a9903d33d74d42bad379281d776f..61a02a3d24de1d97afcee28e22005b89b7bfef60 100644 (file)
@@ -164,5 +164,5 @@ export function cyrb53(str: string, seed: number = 0): string {
     h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
     h2  = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
     h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
-    return (4294967296 * (2097151 & h2) + (h1 >>> 0)) as string;
+    return String((4294967296 * (2097151 & h2) + (h1 >>> 0)));
 }
\ No newline at end of file
index de783705763ee05793e9b96994de9bb33efe6e5b..1fe22b9c4e266bcc56f7b25f52c3ad3194d40a42 100755 (executable)
@@ -219,6 +219,27 @@ body.tox-fullscreen, body.markdown-fullscreen {
   }
 }
 
+// Page inline comments
+.content-comment-highlight {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 0;
+  height: 0;
+  user-select: none;
+  pointer-events: none;
+  &:after {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    background-color: var(--color-primary);
+    opacity: 0.25;
+  }
+}
+
 // Page editor sidebar toolbox
 .floating-toolbox {
   @include mixins.lightDark(background-color, #FFF, #222);
Morty Proxy This is a proxified and sanitized view of the page, visit original site.