]> BookStack Code Mirror - bookstack/commitdiff
Comments: Started logic for content references
authorDan Brown <redacted>
Fri, 18 Apr 2025 14:01:57 +0000 (15:01 +0100)
committerDan Brown <redacted>
Fri, 18 Apr 2025 14:01:57 +0000 (15:01 +0100)
Adds button for comments to pointer.
Adds logic to generate a content reference point.

resources/js/components/pointer.js
resources/js/services/dom.ts
resources/js/services/util.ts
resources/sass/_pages.scss
resources/views/pages/parts/pointer.blade.php

index 292b923e5519f47742990918214c7f46b7e9140b..997df329a846382d66daf0fc3ac78d65b841e00d 100644 (file)
@@ -1,6 +1,9 @@
 import * as DOM from '../services/dom.ts';
 import {Component} from './component';
 import {copyTextToClipboard} from '../services/clipboard.ts';
+import {el} from "../wysiwyg/utils/dom";
+import {cyrb53} from "../services/util";
+import {normalizeNodeTextOffsetToParent} from "../services/dom.ts";
 
 export class Pointer extends Component {
 
@@ -12,13 +15,16 @@ export class Pointer extends Component {
         this.includeInput = this.$refs.includeInput;
         this.includeButton = this.$refs.includeButton;
         this.sectionModeButton = this.$refs.sectionModeButton;
+        this.commentButton = this.$refs.commentButton;
         this.modeToggles = this.$manyRefs.modeToggle;
         this.modeSections = this.$manyRefs.modeSection;
         this.pageId = this.$opts.pageId;
 
         // Instance variables
         this.showing = false;
-        this.isSelection = false;
+        this.isMakingSelection = false;
+        this.targetElement = null;
+        this.targetSelectionRange = null;
 
         this.setupListeners();
     }
@@ -41,7 +47,7 @@ export class Pointer extends Component {
 
         // Hide pointer when clicking away
         DOM.onEvents(document.body, ['click', 'focus'], () => {
-            if (!this.showing || this.isSelection) return;
+            if (!this.showing || this.isMakingSelection) return;
             this.hidePointer();
         });
 
@@ -70,11 +76,17 @@ export class Pointer extends Component {
 
             this.modeToggles.find(b => b !== event.target).focus();
         });
+
+        if (this.commentButton) {
+            DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
+        }
     }
 
     hidePointer() {
         this.pointer.style.display = null;
         this.showing = false;
+        this.targetElement = null;
+        this.targetSelectionRange = null;
     }
 
     /**
@@ -84,7 +96,9 @@ export class Pointer extends Component {
      * @param {Boolean} keyboardMode
      */
     showPointerAtTarget(element, xPosition, keyboardMode) {
-        this.updateForTarget(element);
+        this.targetElement = element;
+        this.targetSelectionRange = window.getSelection()?.getRangeAt(0);
+        this.updateDomForTarget(element);
 
         this.pointer.style.display = 'block';
         const targetBounds = element.getBoundingClientRect();
@@ -98,10 +112,10 @@ export class Pointer extends Component {
         this.pointer.style.top = `${yOffset}px`;
 
         this.showing = true;
-        this.isSelection = true;
+        this.isMakingSelection = true;
 
         setTimeout(() => {
-            this.isSelection = false;
+            this.isMakingSelection = false;
         }, 100);
 
         const scrollListener = () => {
@@ -119,7 +133,7 @@ export class Pointer extends Component {
      * Update the pointer inputs/content for the given target element.
      * @param {?Element} element
      */
-    updateForTarget(element) {
+    updateDomForTarget(element) {
         const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
         const includeTag = `{{@${this.pageId}#${element.id}}}`;
 
@@ -152,4 +166,34 @@ export class Pointer extends Component {
         });
     }
 
+    createCommentAtPointer(event) {
+        if (!this.targetElement) {
+            return;
+        }
+
+        const normalisedElemHtml = this.targetElement.outerHTML.replace(/\s{2,}/g, '');
+        const refId = this.targetElement.id;
+        const hash = cyrb53(normalisedElemHtml);
+        let range = '';
+        if (this.targetSelectionRange) {
+            const commonContainer = this.targetSelectionRange.commonAncestorContainer;
+            if (this.targetElement.contains(commonContainer)) {
+                const start = normalizeNodeTextOffsetToParent(
+                    this.targetSelectionRange.startContainer,
+                    this.targetSelectionRange.startOffset,
+                    this.targetElement
+                );
+                const end = normalizeNodeTextOffsetToParent(
+                    this.targetSelectionRange.endContainer,
+                    this.targetSelectionRange.endOffset,
+                    this.targetElement
+                );
+                range = `${start}-${end}`;
+            }
+        }
+
+        const reference = `${refId}:${hash}:${range}`;
+        console.log(reference);
+    }
+
 }
index c88827bac40a1788b89295152799b62e9871425f..779b4854773ba2bc1b47be2bca6bec658c2a05e1 100644 (file)
@@ -178,3 +178,24 @@ export function htmlToDom(html: string): HTMLElement {
 
     return firstChild;
 }
+
+export function normalizeNodeTextOffsetToParent(node: Node, offset: number, parentElement: HTMLElement): number {
+    if (!parentElement.contains(node)) {
+        throw new Error('ParentElement must be a prent of element');
+    }
+
+    let normalizedOffset = offset;
+    let currentNode: Node|null = node.nodeType === Node.TEXT_NODE ?
+        node : node.childNodes[offset];
+
+    while (currentNode !== parentElement && currentNode) {
+        if (currentNode.previousSibling) {
+            currentNode = currentNode.previousSibling;
+            normalizedOffset += (currentNode.textContent?.length || 0);
+        } else {
+            currentNode = currentNode.parentNode;
+        }
+    }
+
+    return normalizedOffset;
+}
index c5a5d2db804915153b96d197ea81df808916f1ea..1a6fa55b6b06a9903d33d74d42bad379281d776f 100644 (file)
@@ -144,4 +144,25 @@ function getVersion(): string {
 export function importVersioned(moduleName: string): Promise<object> {
     const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`);
     return import(importPath);
+}
+
+/*
+    cyrb53 (c) 2018 bryc (github.com/bryc)
+    License: Public domain (or MIT if needed). Attribution appreciated.
+    A fast and simple 53-bit string hash function with decent collision resistance.
+    Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
+    Taken from: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
+*/
+export function cyrb53(str: string, seed: number = 0): string {
+    let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
+    for(let i = 0, ch; i < str.length; i++) {
+        ch = str.charCodeAt(i);
+        h1 = Math.imul(h1 ^ ch, 2654435761);
+        h2 = Math.imul(h2 ^ ch, 1597334677);
+    }
+    h1  = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
+    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;
 }
\ No newline at end of file
index 45e58ffc865030098c45067e01a876778d896a99..de783705763ee05793e9b96994de9bb33efe6e5b 100755 (executable)
@@ -183,7 +183,6 @@ body.tox-fullscreen, body.markdown-fullscreen {
   }
   input, button, a {
     position: relative;
-    border-radius: 0;
     height: 28px;
     font-size: 12px;
     vertical-align: top;
@@ -194,17 +193,19 @@ body.tox-fullscreen, body.markdown-fullscreen {
     border: 1px solid #DDD;
     @include mixins.lightDark(border-color, #ddd, #000);
     color: #666;
-    width: 160px;
-    z-index: 40;
-    padding: 5px 10px;
+    width: 180px;
+    z-index: 58;
+    padding: 5px;
+    border-radius: 0;
   }
   .text-button {
     @include mixins.lightDark(color, #444, #AAA);
   }
   .input-group .button {
     line-height: 1;
-    margin: 0 0 0 -4px;
+    margin: 0 0 0 -5px;
     box-shadow: none;
+    border-radius: 0;
   }
   a.button {
     margin: 0;
index 56f36cb75f3d91b48d446a86ad8715f4f36a7bab..77fc763827e05049168d477237d922c1284d7c5a 100644 (file)
@@ -6,14 +6,14 @@
          tabindex="-1"
          aria-label="{{ trans('entities.pages_pointer_label') }}"
          class="pointer-container">
-        <div class="pointer flex-container-row items-center justify-space-between p-s anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
-            <div refs="pointer@mode-section" class="flex-container-row items-center gap-s">
+        <div class="pointer flex-container-row items-center justify-space-between gap-xs p-xs anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
+            <div refs="pointer@mode-section" class="flex-container-row items-center gap-xs">
                 <button refs="pointer@mode-toggle"
                         title="{{ trans('entities.pages_pointer_toggle_link') }}"
                         class="text-button icon px-xs">@icon('link')</button>
                 <div class="input-group">
                     <input refs="pointer@link-input" aria-label="{{ trans('entities.pages_pointer_permalink') }}" readonly="readonly" type="text" id="pointer-url" placeholder="url">
-                    <button refs="pointer@link-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                    <button refs="pointer@link-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
                 </div>
             </div>
             <div refs="pointer@mode-section" hidden class="flex-container-row items-center gap-s">
                         class="text-button icon px-xs">@icon('include')</button>
                 <div class="input-group">
                     <input refs="pointer@include-input" aria-label="{{ trans('entities.pages_pointer_include_tag') }}" readonly="readonly" type="text" id="pointer-include" placeholder="include">
-                    <button refs="pointer@include-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                    <button refs="pointer@include-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
                 </div>
             </div>
-            @if(userCan('page-update', $page))
-                <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
-                   class="button primary outline icon heading-edit-icon ml-s px-s" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
-            @endif
+            <div>
+                @if(userCan('page-update', $page))
+                    <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
+                       class="button primary outline icon heading-edit-icon px-xs" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
+                @endif
+                @if($commentTree->enabled() && userCan('comment-create-all'))
+                    <button type="button"
+                            refs="pointer@comment-button"
+                            class="button primary outline icon px-xs m-none" title="{{ trans('entities.comment_add')}}">@icon('comment')</button>
+                @endif
+            </div>
         </div>
     </div>