From: Dan Brown Date: Sat, 19 Apr 2025 13:07:52 +0000 (+0100) Subject: Comments: Added inline comment marker/highlight logic X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/18ede9bbd3bef5e7b82ae52c0dac2a26fe2c24fd Comments: Added inline comment marker/highlight logic --- diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index b2e2bac27..f4d295b95 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -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); + } } diff --git a/resources/js/components/pointer.ts b/resources/js/components/pointer.ts index c3883b7b5..d84186d87 100644 --- a/resources/js/components/pointer.ts +++ b/resources/js/components/pointer.ts @@ -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; diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index 537af816a..661ed7ca3 100644 --- a/resources/js/services/dom.ts +++ b/resources/js/services/dom.ts @@ -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 diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts index 1a6fa55b6..61a02a3d2 100644 --- a/resources/js/services/util.ts +++ b/resources/js/services/util.ts @@ -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 diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index de7837057..1fe22b9c4 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -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);