diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php
index 3336e17e9..7005f8fcf 100644
--- a/app/Activity/CommentRepo.php
+++ b/app/Activity/CommentRepo.php
@@ -4,6 +4,8 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Exceptions\PrettyException;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter;
@@ -20,7 +22,7 @@ class CommentRepo
/**
* Create a new comment on an entity.
*/
- public function create(Entity $entity, string $html, ?int $parent_id): Comment
+ public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
{
$userId = user()->id;
$comment = new Comment();
@@ -29,7 +31,8 @@ class CommentRepo
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
- $comment->parent_id = $parent_id;
+ $comment->parent_id = $parentId;
+ $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
$entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
@@ -52,6 +55,41 @@ class CommentRepo
return $comment;
}
+
+ /**
+ * Archive an existing comment.
+ */
+ public function archive(Comment $comment): Comment
+ {
+ if ($comment->parent_id) {
+ throw new NotifyException('Only top-level comments can be archived.', '/', 400);
+ }
+
+ $comment->archived = true;
+ $comment->save();
+
+ ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+ return $comment;
+ }
+
+ /**
+ * Un-archive an existing comment.
+ */
+ public function unarchive(Comment $comment): Comment
+ {
+ if ($comment->parent_id) {
+ throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
+ }
+
+ $comment->archived = false;
+ $comment->save();
+
+ ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+ return $comment;
+ }
+
/**
* Delete a comment from the system.
*/
diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php
index 52ccc8238..479d57c4d 100644
--- a/app/Activity/Controllers/CommentController.php
+++ b/app/Activity/Controllers/CommentController.php
@@ -3,6 +3,8 @@
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
+use BookStack\Activity\Tools\CommentTree;
+use BookStack\Activity\Tools\CommentTreeNode;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
@@ -26,6 +28,7 @@ class CommentController extends Controller
$input = $this->validate($request, [
'html' => ['required', 'string'],
'parent_id' => ['nullable', 'integer'],
+ 'content_ref' => ['string'],
]);
$page = $this->pageQueries->findVisibleById($pageId);
@@ -40,14 +43,12 @@ class CommentController extends Controller
// Create a new comment.
$this->checkPermission('comment-create-all');
- $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
+ $contentRef = $input['content_ref'] ?? '';
+ $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
return view('comments.comment-branch', [
'readOnly' => false,
- 'branch' => [
- 'comment' => $comment,
- 'children' => [],
- ]
+ 'branch' => new CommentTreeNode($comment, 0, []),
]);
}
@@ -74,6 +75,46 @@ class CommentController extends Controller
]);
}
+ /**
+ * Mark a comment as archived.
+ */
+ public function archive(int $id)
+ {
+ $comment = $this->commentRepo->getById($id);
+ $this->checkOwnablePermission('page-view', $comment->entity);
+ if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+ $this->showPermissionError();
+ }
+
+ $this->commentRepo->archive($comment);
+
+ $tree = new CommentTree($comment->entity);
+ return view('comments.comment-branch', [
+ 'readOnly' => false,
+ 'branch' => $tree->getCommentNodeForId($id),
+ ]);
+ }
+
+ /**
+ * Unmark a comment as archived.
+ */
+ public function unarchive(int $id)
+ {
+ $comment = $this->commentRepo->getById($id);
+ $this->checkOwnablePermission('page-view', $comment->entity);
+ if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+ $this->showPermissionError();
+ }
+
+ $this->commentRepo->unarchive($comment);
+
+ $tree = new CommentTree($comment->entity);
+ return view('comments.comment-branch', [
+ 'readOnly' => false,
+ 'branch' => $tree->getCommentNodeForId($id),
+ ]);
+ }
+
/**
* Delete a comment from the system.
*/
diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php
index d0385d396..91cea4fe0 100644
--- a/app/Activity/Models/Comment.php
+++ b/app/Activity/Models/Comment.php
@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $entity_id
* @property int $created_by
* @property int $updated_by
+ * @property string $content_ref
+ * @property bool $archived
*/
class Comment extends Model implements Loggable
{
diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php
index 16f6804ea..a05a9d247 100644
--- a/app/Activity/Tools/CommentTree.php
+++ b/app/Activity/Tools/CommentTree.php
@@ -9,7 +9,7 @@ class CommentTree
{
/**
* The built nested tree structure array.
- * @var array{comment: Comment, depth: int, children: array}[]
+ * @var CommentTreeNode[]
*/
protected array $tree;
protected array $comments;
@@ -28,7 +28,7 @@ class CommentTree
public function empty(): bool
{
- return count($this->tree) === 0;
+ return count($this->getActive()) === 0;
}
public function count(): int
@@ -36,9 +36,35 @@ class CommentTree
return count($this->comments);
}
- public function get(): array
+ public function getActive(): array
{
- return $this->tree;
+ return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
+ }
+
+ public function activeThreadCount(): int
+ {
+ return count($this->getActive());
+ }
+
+ public function getArchived(): array
+ {
+ return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
+ }
+
+ public function archivedThreadCount(): int
+ {
+ return count($this->getArchived());
+ }
+
+ public function getCommentNodeForId(int $commentId): ?CommentTreeNode
+ {
+ foreach ($this->tree as $node) {
+ if ($node->comment->id === $commentId) {
+ return $node;
+ }
+ }
+
+ return null;
}
public function canUpdateAny(): bool
@@ -54,6 +80,7 @@ class CommentTree
/**
* @param Comment[] $comments
+ * @return CommentTreeNode[]
*/
protected function createTree(array $comments): array
{
@@ -77,26 +104,22 @@ class CommentTree
$tree = [];
foreach ($childMap[0] ?? [] as $childId) {
- $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
+ $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
}
return $tree;
}
- protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
+ protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
{
$childIds = $childMap[$id] ?? [];
$children = [];
foreach ($childIds as $childId) {
- $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
+ $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
}
- return [
- 'comment' => $byId[$id],
- 'depth' => $depth,
- 'children' => $children,
- ];
+ return new CommentTreeNode($byId[$id], $depth, $children);
}
protected function loadComments(): array
diff --git a/app/Activity/Tools/CommentTreeNode.php b/app/Activity/Tools/CommentTreeNode.php
new file mode 100644
index 000000000..7b280bd2d
--- /dev/null
+++ b/app/Activity/Tools/CommentTreeNode.php
@@ -0,0 +1,23 @@
+comment = $comment;
+ $this->depth = $depth;
+ $this->children = $children;
+ }
+}
diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php
index efbd183b3..844bc3993 100644
--- a/database/factories/Activity/Models/CommentFactory.php
+++ b/database/factories/Activity/Models/CommentFactory.php
@@ -27,6 +27,8 @@ class CommentFactory extends Factory
'html' => $html,
'parent_id' => null,
'local_id' => 1,
+ 'content_ref' => '',
+ 'archived' => false,
];
}
}
diff --git a/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php b/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php
new file mode 100644
index 000000000..794201dec
--- /dev/null
+++ b/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php
@@ -0,0 +1,30 @@
+string('content_ref');
+ $table->boolean('archived')->index();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('comments', function (Blueprint $table) {
+ $table->dropColumn('content_ref');
+ $table->dropColumn('archived');
+ });
+ }
+};
diff --git a/lang/en/common.php b/lang/en/common.php
index b05169bb2..06a9e855c 100644
--- a/lang/en/common.php
+++ b/lang/en/common.php
@@ -30,6 +30,8 @@ return [
'create' => 'Create',
'update' => 'Update',
'edit' => 'Edit',
+ 'archive' => 'Archive',
+ 'unarchive' => 'Un-Archive',
'sort' => 'Sort',
'move' => 'Move',
'copy' => 'Copy',
diff --git a/lang/en/entities.php b/lang/en/entities.php
index a74785eaa..6e616ded4 100644
--- a/lang/en/entities.php
+++ b/lang/en/entities.php
@@ -392,8 +392,11 @@ return [
'comment' => 'Comment',
'comments' => 'Comments',
'comment_add' => 'Add Comment',
+ 'comment_none' => 'No comments to display',
'comment_placeholder' => 'Leave a comment here',
- 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+ 'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+ 'comment_archived_count' => ':count Archived',
+ 'comment_archived_threads' => 'Archived Threads',
'comment_save' => 'Save Comment',
'comment_new' => 'New Comment',
'comment_created' => 'commented :createDiff',
@@ -402,8 +405,14 @@ return [
'comment_deleted_success' => 'Comment deleted',
'comment_created_success' => 'Comment added',
'comment_updated_success' => 'Comment updated',
+ 'comment_archive_success' => 'Comment archived',
+ 'comment_unarchive_success' => 'Comment un-archived',
+ 'comment_view' => 'View comment',
+ 'comment_jump_to_thread' => 'Jump to thread',
'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
'comment_in_reply_to' => 'In reply to :commentId',
+ 'comment_reference' => 'Reference',
+ 'comment_reference_outdated' => '(Outdated)',
'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
// Revision
diff --git a/resources/icons/archive.svg b/resources/icons/archive.svg
new file mode 100644
index 000000000..90a4f35b7
--- /dev/null
+++ b/resources/icons/archive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/icons/bookmark.svg b/resources/icons/bookmark.svg
new file mode 100644
index 000000000..30e487c52
--- /dev/null
+++ b/resources/icons/bookmark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/js/components/editor-toolbox.js b/resources/js/components/editor-toolbox.ts
similarity index 57%
rename from resources/js/components/editor-toolbox.js
rename to resources/js/components/editor-toolbox.ts
index ddb4ff39c..60bdde05e 100644
--- a/resources/js/components/editor-toolbox.js
+++ b/resources/js/components/editor-toolbox.ts
@@ -1,42 +1,58 @@
import {Component} from './component';
+export interface EditorToolboxChangeEventData {
+ tab: string;
+ open: boolean;
+}
+
export class EditorToolbox extends Component {
+ protected container!: HTMLElement;
+ protected buttons!: HTMLButtonElement[];
+ protected contentElements!: HTMLElement[];
+ protected toggleButton!: HTMLElement;
+ protected editorWrapEl!: HTMLElement;
+
+ protected open: boolean = false;
+ protected tab: string = '';
+
setup() {
// Elements
this.container = this.$el;
- this.buttons = this.$manyRefs.tabButton;
+ this.buttons = this.$manyRefs.tabButton as HTMLButtonElement[];
this.contentElements = this.$manyRefs.tabContent;
this.toggleButton = this.$refs.toggle;
- this.editorWrapEl = this.container.closest('.page-editor');
+ this.editorWrapEl = this.container.closest('.page-editor') as HTMLElement;
this.setupListeners();
// Set the first tab as active on load
- this.setActiveTab(this.contentElements[0].dataset.tabContent);
+ this.setActiveTab(this.contentElements[0].dataset.tabContent || '');
}
- setupListeners() {
+ protected setupListeners(): void {
// Toolbox toggle button click
this.toggleButton.addEventListener('click', () => this.toggle());
// Tab button click
- this.container.addEventListener('click', event => {
- const button = event.target.closest('button');
- if (this.buttons.includes(button)) {
- const name = button.dataset.tab;
+ this.container.addEventListener('click', (event: MouseEvent) => {
+ const button = (event.target as HTMLElement).closest('button');
+ if (button instanceof HTMLButtonElement && this.buttons.includes(button)) {
+ const name = button.dataset.tab || '';
this.setActiveTab(name, true);
}
});
}
- toggle() {
+ protected toggle(): void {
this.container.classList.toggle('open');
const isOpen = this.container.classList.contains('open');
this.toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
this.editorWrapEl.classList.toggle('toolbox-open', isOpen);
+ this.open = isOpen;
+ this.emitState();
}
- setActiveTab(tabName, openToolbox = false) {
+ protected setActiveTab(tabName: string, openToolbox: boolean = false): void {
// Set button visibility
for (const button of this.buttons) {
button.classList.remove('active');
@@ -54,6 +70,14 @@ export class EditorToolbox extends Component {
if (openToolbox && !this.container.classList.contains('open')) {
this.toggle();
}
+
+ this.tab = tabName;
+ this.emitState();
+ }
+
+ protected emitState(): void {
+ const data: EditorToolboxChangeEventData = {tab: this.tab, open: this.open};
+ this.$emit('change', data);
}
}
diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts
index 10b8025db..63e1ad0db 100644
--- a/resources/js/components/index.ts
+++ b/resources/js/components/index.ts
@@ -36,6 +36,7 @@ export {NewUserPassword} from './new-user-password';
export {Notification} from './notification';
export {OptionalInput} from './optional-input';
export {PageComment} from './page-comment';
+export {PageCommentReference} from './page-comment-reference';
export {PageComments} from './page-comments';
export {PageDisplay} from './page-display';
export {PageEditor} from './page-editor';
diff --git a/resources/js/components/page-comment-reference.ts b/resources/js/components/page-comment-reference.ts
new file mode 100644
index 000000000..009e806c1
--- /dev/null
+++ b/resources/js/components/page-comment-reference.ts
@@ -0,0 +1,251 @@
+import {Component} from "./component";
+import {findTargetNodeAndOffset, hashElement} from "../services/dom";
+import {el} from "../wysiwyg/utils/dom";
+import commentIcon from "@icons/comment.svg";
+import closeIcon from "@icons/close.svg";
+import {debounce, scrollAndHighlightElement} from "../services/util";
+import {EditorToolboxChangeEventData} from "./editor-toolbox";
+import {TabsChangeEvent} from "./tabs";
+
+/**
+ * Track the close function for the current open marker so it can be closed
+ * when another is opened so we only show one marker comment thread at one time.
+ */
+let openMarkerClose: Function|null = null;
+
+export class PageCommentReference extends Component {
+ protected link!: HTMLLinkElement;
+ protected reference!: string;
+ protected markerWrap: HTMLElement|null = null;
+
+ protected viewCommentText!: string;
+ protected jumpToThreadText!: string;
+ protected closeText!: string;
+
+ setup() {
+ this.link = this.$el as HTMLLinkElement;
+ this.reference = this.$opts.reference;
+ this.viewCommentText = this.$opts.viewCommentText;
+ this.jumpToThreadText = this.$opts.jumpToThreadText;
+ this.closeText = this.$opts.closeText;
+
+ // Show within page display area if seen
+ this.showForDisplay();
+
+ // Handle editor view to show on comments toolbox view
+ window.addEventListener('editor-toolbox-change', ((event: CustomEvent) => {
+ const tabName: string = event.detail.tab;
+ const isOpen = event.detail.open;
+ if (tabName === 'comments' && isOpen && this.link.checkVisibility()) {
+ this.showForEditor();
+ } else {
+ this.hideMarker();
+ }
+ }) as EventListener);
+
+ // Handle visibility changes within editor toolbox archived details dropdown
+ window.addEventListener('toggle', event => {
+ if (event.target instanceof HTMLElement && event.target.contains(this.link)) {
+ window.requestAnimationFrame(() => {
+ if (this.link.checkVisibility()) {
+ this.showForEditor();
+ } else {
+ this.hideMarker();
+ }
+ });
+ }
+ }, {capture: true});
+
+ // Handle comments tab changes to hide/show markers & indicators
+ window.addEventListener('tabs-change', ((event: CustomEvent) => {
+ const sectionId = event.detail.showing;
+ if (!sectionId.startsWith('comment-tab-panel')) {
+ return;
+ }
+
+ const panel = document.getElementById(sectionId);
+ if (panel?.contains(this.link)) {
+ this.showForDisplay();
+ } else {
+ this.hideMarker();
+ }
+ }) as EventListener);
+ }
+
+ public showForDisplay() {
+ const pageContentArea = document.querySelector('.page-content');
+ if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
+ this.updateMarker(pageContentArea);
+ }
+ }
+
+ protected showForEditor() {
+ const contentWrap = document.querySelector('.editor-content-wrap');
+ if (contentWrap instanceof HTMLElement) {
+ this.updateMarker(contentWrap);
+ }
+
+ const onChange = () => {
+ this.hideMarker();
+ setTimeout(() => {
+ window.$events.remove('editor-html-change', onChange);
+ }, 1);
+ };
+
+ window.$events.listen('editor-html-change', onChange);
+ }
+
+ protected updateMarker(contentContainer: HTMLElement) {
+ // Reset link and existing marker
+ this.link.classList.remove('outdated', 'missing');
+ if (this.markerWrap) {
+ this.markerWrap.remove();
+ }
+
+ const [refId, refHash, refRange] = this.reference.split(':');
+ const refEl = document.getElementById(refId);
+ if (!refEl) {
+ this.link.classList.add('outdated', 'missing');
+ return;
+ }
+
+ const actualHash = hashElement(refEl);
+ if (actualHash !== refHash) {
+ this.link.classList.add('outdated');
+ }
+
+ const marker = el('button', {
+ type: 'button',
+ class: 'content-comment-marker',
+ title: this.viewCommentText,
+ });
+ marker.innerHTML = commentIcon;
+ marker.addEventListener('click', event => {
+ this.showCommentAtMarker(marker);
+ });
+
+ this.markerWrap = el('div', {
+ class: 'content-comment-highlight',
+ }, [marker]);
+
+ contentContainer.append(this.markerWrap);
+ this.positionMarker(refEl, refRange);
+
+ this.link.href = `#${refEl.id}`;
+ this.link.addEventListener('click', (event: MouseEvent) => {
+ event.preventDefault();
+ scrollAndHighlightElement(refEl);
+ });
+
+ const debouncedReposition = debounce(() => {
+ this.positionMarker(refEl, refRange);
+ }, 50, false).bind(this);
+ window.addEventListener('resize', debouncedReposition);
+ }
+
+ protected positionMarker(targetEl: HTMLElement, range: string) {
+ if (!this.markerWrap) {
+ return;
+ }
+
+ const markerParent = this.markerWrap.parentElement as HTMLElement;
+ const parentBounds = markerParent.getBoundingClientRect();
+ let targetBounds = targetEl.getBoundingClientRect();
+ const [rangeStart, rangeEnd] = range.split('-');
+ if (rangeStart && rangeEnd) {
+ const range = new Range();
+ const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
+ const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
+ if (relStart && relEnd) {
+ range.setStart(relStart.node, relStart.offset);
+ range.setEnd(relEnd.node, relEnd.offset);
+ targetBounds = range.getBoundingClientRect();
+ }
+ }
+
+ const relLeft = targetBounds.left - parentBounds.left;
+ const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
+
+ this.markerWrap.style.left = `${relLeft}px`;
+ this.markerWrap.style.top = `${relTop}px`;
+ this.markerWrap.style.width = `${targetBounds.width}px`;
+ this.markerWrap.style.height = `${targetBounds.height}px`;
+ }
+
+ public hideMarker() {
+ // Hide marker and close existing marker windows
+ if (openMarkerClose) {
+ openMarkerClose();
+ }
+ this.markerWrap?.remove();
+ this.markerWrap = null;
+ }
+
+ protected showCommentAtMarker(marker: HTMLElement): void {
+ // Hide marker and close existing marker windows
+ if (openMarkerClose) {
+ openMarkerClose();
+ }
+ marker.hidden = true;
+
+ // Locate relevant comment
+ const commentBox = this.link.closest('.comment-box') as HTMLElement;
+
+ // Build comment window
+ const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
+ const toRemove = readClone.querySelectorAll('.actions, form');
+ for (const el of toRemove) {
+ el.remove();
+ }
+
+ const close = el('button', {type: 'button', title: this.closeText});
+ close.innerHTML = (closeIcon as string);
+ const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
+
+ const commentWindow = el('div', {
+ class: 'content-comment-window'
+ }, [
+ el('div', {
+ class: 'content-comment-window-actions',
+ }, [jump, close]),
+ el('div', {
+ class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
+ }, [readClone]),
+ ]);
+
+ marker.parentElement?.append(commentWindow);
+
+ // Handle interaction within window
+ const closeAction = () => {
+ commentWindow.remove();
+ marker.hidden = false;
+ window.removeEventListener('click', windowCloseAction);
+ openMarkerClose = null;
+ };
+
+ const windowCloseAction = (event: MouseEvent) => {
+ if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
+ closeAction();
+ }
+ };
+ window.addEventListener('click', windowCloseAction);
+
+ openMarkerClose = closeAction;
+ close.addEventListener('click', closeAction.bind(this));
+ jump.addEventListener('click', () => {
+ closeAction();
+ commentBox.scrollIntoView({behavior: 'smooth'});
+ const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
+ highlightTarget.classList.add('anim-highlight');
+ highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
+ });
+
+ // Position window within bounds
+ const commentWindowBounds = commentWindow.getBoundingClientRect();
+ const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
+ if (contentBounds && commentWindowBounds.right > contentBounds.right) {
+ const diff = commentWindowBounds.right - contentBounds.right;
+ commentWindow.style.left = `-${diff}px`;
+ }
+ }
+}
\ No newline at end of file
diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js
deleted file mode 100644
index 8c0a8b33e..000000000
--- a/resources/js/components/page-comment.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComment extends Component {
-
- setup() {
- // Options
- this.commentId = this.$opts.commentId;
- this.commentLocalId = this.$opts.commentLocalId;
- this.commentParentId = this.$opts.commentParentId;
- this.deletedText = this.$opts.deletedText;
- this.updatedText = this.$opts.updatedText;
-
- // Editor reference and text options
- this.wysiwygEditor = null;
- this.wysiwygLanguage = this.$opts.wysiwygLanguage;
- this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
-
- // Element references
- this.container = this.$el;
- this.contentContainer = this.$refs.contentContainer;
- this.form = this.$refs.form;
- this.formCancel = this.$refs.formCancel;
- this.editButton = this.$refs.editButton;
- this.deleteButton = this.$refs.deleteButton;
- this.replyButton = this.$refs.replyButton;
- this.input = this.$refs.input;
-
- this.setupListeners();
- }
-
- setupListeners() {
- if (this.replyButton) {
- this.replyButton.addEventListener('click', () => this.$emit('reply', {
- id: this.commentLocalId,
- element: this.container,
- }));
- }
-
- if (this.editButton) {
- this.editButton.addEventListener('click', this.startEdit.bind(this));
- this.form.addEventListener('submit', this.update.bind(this));
- this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
- }
-
- if (this.deleteButton) {
- this.deleteButton.addEventListener('click', this.delete.bind(this));
- }
- }
-
- toggleEditMode(show) {
- this.contentContainer.toggleAttribute('hidden', show);
- this.form.toggleAttribute('hidden', !show);
- }
-
- startEdit() {
- this.toggleEditMode(true);
-
- if (this.wysiwygEditor) {
- this.wysiwygEditor.focus();
- return;
- }
-
- const config = buildForInput({
- language: this.wysiwygLanguage,
- containerElement: this.input,
- darkMode: document.documentElement.classList.contains('dark-mode'),
- textDirection: this.wysiwygTextDirection,
- translations: {},
- translationMap: window.editor_translations,
- });
-
- window.tinymce.init(config).then(editors => {
- this.wysiwygEditor = editors[0];
- setTimeout(() => this.wysiwygEditor.focus(), 50);
- });
- }
-
- async update(event) {
- event.preventDefault();
- const loading = this.showLoading();
- this.form.toggleAttribute('hidden', true);
-
- const reqData = {
- html: this.wysiwygEditor.getContent(),
- parent_id: this.parentId || null,
- };
-
- try {
- const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
- const newComment = htmlToDom(resp.data);
- this.container.replaceWith(newComment);
- window.$events.success(this.updatedText);
- } catch (err) {
- console.error(err);
- window.$events.showValidationErrors(err);
- this.form.toggleAttribute('hidden', false);
- loading.remove();
- }
- }
-
- async delete() {
- this.showLoading();
-
- await window.$http.delete(`/comment/${this.commentId}`);
- this.$emit('delete');
- this.container.closest('.comment-branch').remove();
- window.$events.success(this.deletedText);
- }
-
- showLoading() {
- const loading = getLoading();
- loading.classList.add('px-l');
- this.container.append(loading);
- return loading;
- }
-
-}
diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts
new file mode 100644
index 000000000..0c3e19f4b
--- /dev/null
+++ b/resources/js/components/page-comment.ts
@@ -0,0 +1,184 @@
+import {Component} from './component';
+import {getLoading, htmlToDom} from '../services/dom';
+import {buildForInput} from '../wysiwyg-tinymce/config';
+import {PageCommentReference} from "./page-comment-reference";
+import {HttpError} from "../services/http";
+
+export interface PageCommentReplyEventData {
+ id: string; // ID of comment being replied to
+ element: HTMLElement; // Container for comment replied to
+}
+
+export interface PageCommentArchiveEventData {
+ new_thread_dom: HTMLElement;
+}
+
+export class PageComment extends Component {
+
+ protected commentId!: string;
+ protected commentLocalId!: string;
+ protected deletedText!: string;
+ protected updatedText!: string;
+ protected archiveText!: string;
+
+ protected wysiwygEditor: any = null;
+ protected wysiwygLanguage!: string;
+ protected wysiwygTextDirection!: string;
+
+ protected container!: HTMLElement;
+ protected contentContainer!: HTMLElement;
+ protected form!: HTMLFormElement;
+ protected formCancel!: HTMLElement;
+ protected editButton!: HTMLElement;
+ protected deleteButton!: HTMLElement;
+ protected replyButton!: HTMLElement;
+ protected archiveButton!: HTMLElement;
+ protected input!: HTMLInputElement;
+
+ setup() {
+ // Options
+ this.commentId = this.$opts.commentId;
+ this.commentLocalId = this.$opts.commentLocalId;
+ this.deletedText = this.$opts.deletedText;
+ this.deletedText = this.$opts.deletedText;
+ this.archiveText = this.$opts.archiveText;
+
+ // Editor reference and text options
+ this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+ this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+ // Element references
+ this.container = this.$el;
+ this.contentContainer = this.$refs.contentContainer;
+ this.form = this.$refs.form as HTMLFormElement;
+ this.formCancel = this.$refs.formCancel;
+ this.editButton = this.$refs.editButton;
+ this.deleteButton = this.$refs.deleteButton;
+ this.replyButton = this.$refs.replyButton;
+ this.archiveButton = this.$refs.archiveButton;
+ this.input = this.$refs.input as HTMLInputElement;
+
+ this.setupListeners();
+ }
+
+ protected setupListeners(): void {
+ if (this.replyButton) {
+ const data: PageCommentReplyEventData = {
+ id: this.commentLocalId,
+ element: this.container,
+ };
+ this.replyButton.addEventListener('click', () => this.$emit('reply', data));
+ }
+
+ if (this.editButton) {
+ this.editButton.addEventListener('click', this.startEdit.bind(this));
+ this.form.addEventListener('submit', this.update.bind(this));
+ this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
+ }
+
+ if (this.deleteButton) {
+ this.deleteButton.addEventListener('click', this.delete.bind(this));
+ }
+
+ if (this.archiveButton) {
+ this.archiveButton.addEventListener('click', this.archive.bind(this));
+ }
+ }
+
+ protected toggleEditMode(show: boolean) : void {
+ this.contentContainer.toggleAttribute('hidden', show);
+ this.form.toggleAttribute('hidden', !show);
+ }
+
+ protected startEdit() : void {
+ this.toggleEditMode(true);
+
+ if (this.wysiwygEditor) {
+ this.wysiwygEditor.focus();
+ return;
+ }
+
+ const config = buildForInput({
+ language: this.wysiwygLanguage,
+ containerElement: this.input,
+ darkMode: document.documentElement.classList.contains('dark-mode'),
+ textDirection: this.wysiwygTextDirection,
+ drawioUrl: '',
+ pageId: 0,
+ translations: {},
+ translationMap: (window as unknown as Record).editor_translations,
+ });
+
+ (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => {
+ this.wysiwygEditor = editors[0];
+ setTimeout(() => this.wysiwygEditor.focus(), 50);
+ });
+ }
+
+ protected async update(event: Event): Promise {
+ event.preventDefault();
+ const loading = this.showLoading();
+ this.form.toggleAttribute('hidden', true);
+
+ const reqData = {
+ html: this.wysiwygEditor.getContent(),
+ };
+
+ try {
+ const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
+ const newComment = htmlToDom(resp.data as string);
+ this.container.replaceWith(newComment);
+ window.$events.success(this.updatedText);
+ } catch (err) {
+ console.error(err);
+ if (err instanceof HttpError) {
+ window.$events.showValidationErrors(err);
+ }
+ this.form.toggleAttribute('hidden', false);
+ loading.remove();
+ }
+ }
+
+ protected async delete(): Promise {
+ this.showLoading();
+
+ await window.$http.delete(`/comment/${this.commentId}`);
+ this.$emit('delete');
+
+ const branch = this.container.closest('.comment-branch');
+ if (branch instanceof HTMLElement) {
+ const refs = window.$components.allWithinElement(branch, 'page-comment-reference');
+ for (const ref of refs) {
+ ref.hideMarker();
+ }
+ branch.remove();
+ }
+
+ window.$events.success(this.deletedText);
+ }
+
+ protected async archive(): Promise {
+ this.showLoading();
+ const isArchived = this.archiveButton.dataset.isArchived === 'true';
+ const action = isArchived ? 'unarchive' : 'archive';
+
+ const response = await window.$http.put(`/comment/${this.commentId}/${action}`);
+ window.$events.success(this.archiveText);
+ const eventData: PageCommentArchiveEventData = {new_thread_dom: htmlToDom(response.data as string)};
+ this.$emit(action, eventData);
+
+ const branch = this.container.closest('.comment-branch') as HTMLElement;
+ const references = window.$components.allWithinElement(branch, 'page-comment-reference');
+ for (const reference of references) {
+ reference.hideMarker();
+ }
+ branch.remove();
+ }
+
+ protected showLoading(): HTMLElement {
+ const loading = getLoading();
+ loading.classList.add('px-l');
+ this.container.append(loading);
+ return loading;
+ }
+}
diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js
deleted file mode 100644
index 8f023836b..000000000
--- a/resources/js/components/page-comments.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComments extends Component {
-
- setup() {
- this.elem = this.$el;
- this.pageId = Number(this.$opts.pageId);
-
- // Element references
- this.container = this.$refs.commentContainer;
- this.commentCountBar = this.$refs.commentCountBar;
- this.commentsTitle = this.$refs.commentsTitle;
- this.addButtonContainer = this.$refs.addButtonContainer;
- this.replyToRow = this.$refs.replyToRow;
- this.formContainer = this.$refs.formContainer;
- this.form = this.$refs.form;
- this.formInput = this.$refs.formInput;
- this.formReplyLink = this.$refs.formReplyLink;
- this.addCommentButton = this.$refs.addCommentButton;
- this.hideFormButton = this.$refs.hideFormButton;
- this.removeReplyToButton = this.$refs.removeReplyToButton;
-
- // WYSIWYG options
- this.wysiwygLanguage = this.$opts.wysiwygLanguage;
- this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
- this.wysiwygEditor = null;
-
- // Translations
- this.createdText = this.$opts.createdText;
- this.countText = this.$opts.countText;
-
- // Internal State
- this.parentId = null;
- this.formReplyText = this.formReplyLink?.textContent || '';
-
- this.setupListeners();
- }
-
- setupListeners() {
- this.elem.addEventListener('page-comment-delete', () => {
- setTimeout(() => this.updateCount(), 1);
- this.hideForm();
- });
-
- this.elem.addEventListener('page-comment-reply', event => {
- this.setReply(event.detail.id, event.detail.element);
- });
-
- if (this.form) {
- this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
- this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
- this.addCommentButton.addEventListener('click', this.showForm.bind(this));
- this.form.addEventListener('submit', this.saveComment.bind(this));
- }
- }
-
- saveComment(event) {
- event.preventDefault();
- event.stopPropagation();
-
- const loading = getLoading();
- loading.classList.add('px-l');
- this.form.after(loading);
- this.form.toggleAttribute('hidden', true);
-
- const reqData = {
- html: this.wysiwygEditor.getContent(),
- parent_id: this.parentId || null,
- };
-
- window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
- const newElem = htmlToDom(resp.data);
-
- if (reqData.parent_id) {
- this.formContainer.after(newElem);
- } else {
- this.container.append(newElem);
- }
-
- window.$events.success(this.createdText);
- this.hideForm();
- this.updateCount();
- }).catch(err => {
- this.form.toggleAttribute('hidden', false);
- window.$events.showValidationErrors(err);
- });
-
- this.form.toggleAttribute('hidden', false);
- loading.remove();
- }
-
- updateCount() {
- const count = this.getCommentCount();
- this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count});
- }
-
- resetForm() {
- this.removeEditor();
- this.formInput.value = '';
- this.parentId = null;
- this.replyToRow.toggleAttribute('hidden', true);
- this.container.append(this.formContainer);
- }
-
- showForm() {
- this.removeEditor();
- this.formContainer.toggleAttribute('hidden', false);
- this.addButtonContainer.toggleAttribute('hidden', true);
- this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
- this.loadEditor();
- }
-
- hideForm() {
- this.resetForm();
- this.formContainer.toggleAttribute('hidden', true);
- if (this.getCommentCount() > 0) {
- this.elem.append(this.addButtonContainer);
- } else {
- this.commentCountBar.append(this.addButtonContainer);
- }
- this.addButtonContainer.toggleAttribute('hidden', false);
- }
-
- loadEditor() {
- if (this.wysiwygEditor) {
- this.wysiwygEditor.focus();
- return;
- }
-
- const config = buildForInput({
- language: this.wysiwygLanguage,
- containerElement: this.formInput,
- darkMode: document.documentElement.classList.contains('dark-mode'),
- textDirection: this.wysiwygTextDirection,
- translations: {},
- translationMap: window.editor_translations,
- });
-
- window.tinymce.init(config).then(editors => {
- this.wysiwygEditor = editors[0];
- setTimeout(() => this.wysiwygEditor.focus(), 50);
- });
- }
-
- removeEditor() {
- if (this.wysiwygEditor) {
- this.wysiwygEditor.remove();
- this.wysiwygEditor = null;
- }
- }
-
- getCommentCount() {
- return this.container.querySelectorAll('[component="page-comment"]').length;
- }
-
- setReply(commentLocalId, commentElement) {
- const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children');
- targetFormLocation.append(this.formContainer);
- this.showForm();
- this.parentId = commentLocalId;
- this.replyToRow.toggleAttribute('hidden', false);
- this.formReplyLink.textContent = this.formReplyText.replace('1234', this.parentId);
- this.formReplyLink.href = `#comment${this.parentId}`;
- }
-
- removeReplyTo() {
- this.parentId = null;
- this.replyToRow.toggleAttribute('hidden', true);
- this.container.append(this.formContainer);
- this.showForm();
- }
-
-}
diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts
new file mode 100644
index 000000000..94f5ab3bb
--- /dev/null
+++ b/resources/js/components/page-comments.ts
@@ -0,0 +1,260 @@
+import {Component} from './component';
+import {getLoading, htmlToDom} from '../services/dom';
+import {buildForInput} from '../wysiwyg-tinymce/config';
+import {Tabs} from "./tabs";
+import {PageCommentReference} from "./page-comment-reference";
+import {scrollAndHighlightElement} from "../services/util";
+import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
+
+export class PageComments extends Component {
+
+ private elem!: HTMLElement;
+ private pageId!: number;
+ private container!: HTMLElement;
+ private commentCountBar!: HTMLElement;
+ private activeTab!: HTMLElement;
+ private archivedTab!: HTMLElement;
+ private addButtonContainer!: HTMLElement;
+ private archiveContainer!: HTMLElement;
+ private replyToRow!: HTMLElement;
+ private referenceRow!: HTMLElement;
+ private formContainer!: HTMLElement;
+ private form!: HTMLFormElement;
+ private formInput!: HTMLInputElement;
+ private formReplyLink!: HTMLAnchorElement;
+ private formReferenceLink!: HTMLAnchorElement;
+ private addCommentButton!: HTMLElement;
+ private hideFormButton!: HTMLElement;
+ private removeReplyToButton!: HTMLElement;
+ private removeReferenceButton!: HTMLElement;
+ private wysiwygLanguage!: string;
+ private wysiwygTextDirection!: string;
+ private wysiwygEditor: any = null;
+ private createdText!: string;
+ private countText!: string;
+ private archivedCountText!: string;
+ private parentId: number | null = null;
+ private contentReference: string = '';
+ private formReplyText: string = '';
+
+ setup() {
+ this.elem = this.$el;
+ this.pageId = Number(this.$opts.pageId);
+
+ // Element references
+ this.container = this.$refs.commentContainer;
+ this.commentCountBar = this.$refs.commentCountBar;
+ this.activeTab = this.$refs.activeTab;
+ this.archivedTab = this.$refs.archivedTab;
+ this.addButtonContainer = this.$refs.addButtonContainer;
+ this.archiveContainer = this.$refs.archiveContainer;
+ this.replyToRow = this.$refs.replyToRow;
+ this.referenceRow = this.$refs.referenceRow;
+ this.formContainer = this.$refs.formContainer;
+ this.form = this.$refs.form as HTMLFormElement;
+ this.formInput = this.$refs.formInput as HTMLInputElement;
+ this.formReplyLink = this.$refs.formReplyLink as HTMLAnchorElement;
+ this.formReferenceLink = this.$refs.formReferenceLink as HTMLAnchorElement;
+ this.addCommentButton = this.$refs.addCommentButton;
+ this.hideFormButton = this.$refs.hideFormButton;
+ this.removeReplyToButton = this.$refs.removeReplyToButton;
+ this.removeReferenceButton = this.$refs.removeReferenceButton;
+
+ // WYSIWYG options
+ this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+ this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+ // Translations
+ this.createdText = this.$opts.createdText;
+ this.countText = this.$opts.countText;
+ this.archivedCountText = this.$opts.archivedCountText;
+
+ this.formReplyText = this.formReplyLink?.textContent || '';
+
+ this.setupListeners();
+ }
+
+ protected setupListeners(): void {
+ this.elem.addEventListener('page-comment-delete', () => {
+ setTimeout(() => this.updateCount(), 1);
+ this.hideForm();
+ });
+
+ this.elem.addEventListener('page-comment-reply', ((event: CustomEvent) => {
+ this.setReply(event.detail.id, event.detail.element);
+ }) as EventListener);
+
+ this.elem.addEventListener('page-comment-archive', ((event: CustomEvent) => {
+ this.archiveContainer.append(event.detail.new_thread_dom);
+ setTimeout(() => this.updateCount(), 1);
+ }) as EventListener);
+
+ this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent) => {
+ this.container.append(event.detail.new_thread_dom);
+ setTimeout(() => this.updateCount(), 1);
+ }) as EventListener);
+
+ if (this.form) {
+ this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
+ this.removeReferenceButton.addEventListener('click', () => this.setContentReference(''));
+ this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
+ this.addCommentButton.addEventListener('click', this.showForm.bind(this));
+ this.form.addEventListener('submit', this.saveComment.bind(this));
+ }
+ }
+
+ protected saveComment(event: SubmitEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const loading = getLoading();
+ loading.classList.add('px-l');
+ this.form.after(loading);
+ this.form.toggleAttribute('hidden', true);
+
+ const reqData = {
+ html: this.wysiwygEditor.getContent(),
+ parent_id: this.parentId || null,
+ content_ref: this.contentReference,
+ };
+
+ window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
+ const newElem = htmlToDom(resp.data as string);
+
+ if (reqData.parent_id) {
+ this.formContainer.after(newElem);
+ } else {
+ this.container.append(newElem);
+ }
+
+ const refs = window.$components.allWithinElement(newElem, 'page-comment-reference');
+ for (const ref of refs) {
+ ref.showForDisplay();
+ }
+
+ window.$events.success(this.createdText);
+ this.hideForm();
+ this.updateCount();
+ }).catch(err => {
+ this.form.toggleAttribute('hidden', false);
+ window.$events.showValidationErrors(err);
+ });
+
+ this.form.toggleAttribute('hidden', false);
+ loading.remove();
+ }
+
+ protected updateCount(): void {
+ const activeCount = this.getActiveThreadCount();
+ this.activeTab.textContent = window.$trans.choice(this.countText, activeCount);
+ const archivedCount = this.getArchivedThreadCount();
+ this.archivedTab.textContent = window.$trans.choice(this.archivedCountText, archivedCount);
+ }
+
+ protected resetForm(): void {
+ this.removeEditor();
+ this.formInput.value = '';
+ this.setContentReference('');
+ this.removeReplyTo();
+ }
+
+ protected showForm(): void {
+ this.removeEditor();
+ this.formContainer.toggleAttribute('hidden', false);
+ this.addButtonContainer.toggleAttribute('hidden', true);
+ this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+ this.loadEditor();
+
+ // Ensure the active comments tab is displaying
+ const tabs = window.$components.firstOnElement(this.elem, 'tabs');
+ if (tabs instanceof Tabs) {
+ tabs.show('comment-tab-panel-active');
+ }
+ }
+
+ protected hideForm(): void {
+ this.resetForm();
+ this.formContainer.toggleAttribute('hidden', true);
+ if (this.getActiveThreadCount() > 0) {
+ this.elem.append(this.addButtonContainer);
+ } else {
+ this.commentCountBar.append(this.addButtonContainer);
+ }
+ this.addButtonContainer.toggleAttribute('hidden', false);
+ }
+
+ protected loadEditor(): void {
+ if (this.wysiwygEditor) {
+ this.wysiwygEditor.focus();
+ return;
+ }
+
+ const config = buildForInput({
+ language: this.wysiwygLanguage,
+ containerElement: this.formInput,
+ darkMode: document.documentElement.classList.contains('dark-mode'),
+ textDirection: this.wysiwygTextDirection,
+ drawioUrl: '',
+ pageId: 0,
+ translations: {},
+ translationMap: (window as unknown as Record).editor_translations,
+ });
+
+ (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => {
+ this.wysiwygEditor = editors[0];
+ setTimeout(() => this.wysiwygEditor.focus(), 50);
+ });
+ }
+
+ protected removeEditor(): void {
+ if (this.wysiwygEditor) {
+ this.wysiwygEditor.remove();
+ this.wysiwygEditor = null;
+ }
+ }
+
+ protected getActiveThreadCount(): number {
+ return this.container.querySelectorAll(':scope > .comment-branch:not([hidden])').length;
+ }
+
+ protected getArchivedThreadCount(): number {
+ return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length;
+ }
+
+ protected setReply(commentLocalId: string, commentElement: HTMLElement): void {
+ const targetFormLocation = (commentElement.closest('.comment-branch') as HTMLElement).querySelector('.comment-branch-children') as HTMLElement;
+ targetFormLocation.append(this.formContainer);
+ this.showForm();
+ this.parentId = Number(commentLocalId);
+ this.replyToRow.toggleAttribute('hidden', false);
+ this.formReplyLink.textContent = this.formReplyText.replace('1234', String(this.parentId));
+ this.formReplyLink.href = `#comment${this.parentId}`;
+ }
+
+ protected removeReplyTo(): void {
+ this.parentId = null;
+ this.replyToRow.toggleAttribute('hidden', true);
+ this.container.append(this.formContainer);
+ this.showForm();
+ }
+
+ public startNewComment(contentReference: string): void {
+ this.removeReplyTo();
+ this.setContentReference(contentReference);
+ }
+
+ protected setContentReference(reference: string): void {
+ this.contentReference = reference;
+ this.referenceRow.toggleAttribute('hidden', !Boolean(reference));
+ const [id] = reference.split(':');
+ this.formReferenceLink.href = `#${id}`;
+ this.formReferenceLink.onclick = function(event) {
+ event.preventDefault();
+ const el = document.getElementById(id);
+ if (el) {
+ scrollAndHighlightElement(el);
+ }
+ };
+ }
+
+}
diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.ts
similarity index 50%
rename from resources/js/components/pointer.js
rename to resources/js/components/pointer.ts
index 292b923e5..4b927045a 100644
--- a/resources/js/components/pointer.js
+++ b/resources/js/components/pointer.ts
@@ -1,25 +1,39 @@
-import * as DOM from '../services/dom.ts';
+import * as DOM from '../services/dom';
import {Component} from './component';
-import {copyTextToClipboard} from '../services/clipboard.ts';
+import {copyTextToClipboard} from '../services/clipboard';
+import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom";
+import {PageComments} from "./page-comments";
export class Pointer extends Component {
+ protected showing: boolean = false;
+ protected isMakingSelection: boolean = false;
+ protected targetElement: HTMLElement|null = null;
+ protected targetSelectionRange: Range|null = null;
+
+ protected pointer!: HTMLElement;
+ protected linkInput!: HTMLInputElement;
+ protected linkButton!: HTMLElement;
+ protected includeInput!: HTMLInputElement;
+ protected includeButton!: HTMLElement;
+ protected sectionModeButton!: HTMLElement;
+ protected commentButton!: HTMLElement;
+ protected modeToggles!: HTMLElement[];
+ protected modeSections!: HTMLElement[];
+ protected pageId!: string;
+
setup() {
- this.container = this.$el;
this.pointer = this.$refs.pointer;
- this.linkInput = this.$refs.linkInput;
+ this.linkInput = this.$refs.linkInput as HTMLInputElement;
this.linkButton = this.$refs.linkButton;
- this.includeInput = this.$refs.includeInput;
+ this.includeInput = this.$refs.includeInput as HTMLInputElement;
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.setupListeners();
}
@@ -30,7 +44,7 @@ export class Pointer extends Component {
// Select all contents on input click
DOM.onSelect([this.includeInput, this.linkInput], event => {
- event.target.select();
+ (event.target as HTMLInputElement).select();
event.stopPropagation();
});
@@ -41,7 +55,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();
});
@@ -52,9 +66,10 @@ export class Pointer extends Component {
const pageContent = document.querySelector('.page-content');
DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
event.stopPropagation();
- const targetEl = event.target.closest('[id^="bkmrk"]');
- if (targetEl && window.getSelection().toString().length > 0) {
- this.showPointerAtTarget(targetEl, event.pageX, false);
+ const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]');
+ if (targetEl instanceof HTMLElement && (window.getSelection() || '').toString().length > 0) {
+ const xPos = (event instanceof MouseEvent) ? event.pageX : 0;
+ this.showPointerAtTarget(targetEl, xPos, false);
}
});
@@ -63,28 +78,35 @@ export class Pointer extends Component {
// Toggle between pointer modes
DOM.onSelect(this.modeToggles, event => {
+ const targetToggle = (event.target as HTMLElement);
for (const section of this.modeSections) {
- const show = !section.contains(event.target);
+ const show = !section.contains(targetToggle);
section.toggleAttribute('hidden', !show);
}
- this.modeToggles.find(b => b !== event.target).focus();
+ const otherToggle = this.modeToggles.find(b => b !== targetToggle);
+ otherToggle && otherToggle.focus();
});
+
+ if (this.commentButton) {
+ DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
+ }
}
hidePointer() {
- this.pointer.style.display = null;
+ this.pointer.style.removeProperty('display');
this.showing = false;
+ this.targetElement = null;
+ this.targetSelectionRange = null;
}
/**
* Move and display the pointer at the given element, targeting the given screen x-position if possible.
- * @param {Element} element
- * @param {Number} xPosition
- * @param {Boolean} keyboardMode
*/
- showPointerAtTarget(element, xPosition, keyboardMode) {
- this.updateForTarget(element);
+ showPointerAtTarget(element: HTMLElement, xPosition: number, keyboardMode: boolean) {
+ this.targetElement = element;
+ this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
+ this.updateDomForTarget(element);
this.pointer.style.display = 'block';
const targetBounds = element.getBoundingClientRect();
@@ -98,18 +120,18 @@ 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 = () => {
this.hidePointer();
- window.removeEventListener('scroll', scrollListener, {passive: true});
+ window.removeEventListener('scroll', scrollListener);
};
- element.parentElement.insertBefore(this.pointer, element);
+ element.parentElement?.insertBefore(this.pointer, element);
if (!keyboardMode) {
window.addEventListener('scroll', scrollListener, {passive: true});
}
@@ -117,9 +139,8 @@ export class Pointer extends Component {
/**
* Update the pointer inputs/content for the given target element.
- * @param {?Element} element
*/
- updateForTarget(element) {
+ updateDomForTarget(element: HTMLElement) {
const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
const includeTag = `{{@${this.pageId}#${element.id}}}`;
@@ -128,18 +149,18 @@ export class Pointer extends Component {
// Update anchor if present
const editAnchor = this.pointer.querySelector('#pointer-edit');
- if (editAnchor && element) {
+ if (editAnchor instanceof HTMLAnchorElement && element) {
const {editHref} = editAnchor.dataset;
const elementId = element.id;
// Get the first 50 characters.
- const queryContent = element.textContent && element.textContent.substring(0, 50);
+ const queryContent = (element.textContent || '').substring(0, 50);
editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
}
}
enterSectionSelectMode() {
- const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
+ const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]')) as HTMLElement[];
for (const section of sections) {
section.setAttribute('tabindex', '0');
}
@@ -147,9 +168,39 @@ export class Pointer extends Component {
sections[0].focus();
DOM.onEnterPress(sections, event => {
- this.showPointerAtTarget(event.target, 0, true);
+ this.showPointerAtTarget(event.target as HTMLElement, 0, true);
this.pointer.focus();
});
}
+ createCommentAtPointer() {
+ if (!this.targetElement) {
+ return;
+ }
+
+ const refId = this.targetElement.id;
+ const hash = hashElement(this.targetElement);
+ 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}`;
+ const pageComments = window.$components.first('page-comments') as PageComments;
+ pageComments.startNewComment(reference);
+ }
+
}
diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.ts
similarity index 74%
rename from resources/js/components/tabs.js
rename to resources/js/components/tabs.ts
index f0fc058ce..a03d37cd4 100644
--- a/resources/js/components/tabs.js
+++ b/resources/js/components/tabs.ts
@@ -1,5 +1,9 @@
import {Component} from './component';
+export interface TabsChangeEvent {
+ showing: string;
+}
+
/**
* Tabs
* Uses accessible attributes to drive its functionality.
@@ -19,18 +23,25 @@ import {Component} from './component';
*/
export class Tabs extends Component {
+ protected container!: HTMLElement;
+ protected tabList!: HTMLElement;
+ protected tabs!: HTMLElement[];
+ protected panels!: HTMLElement[];
+
+ protected activeUnder!: number;
+ protected active: null|boolean = null;
+
setup() {
this.container = this.$el;
- this.tabList = this.container.querySelector('[role="tablist"]');
+ this.tabList = this.container.querySelector('[role="tablist"]') as HTMLElement;
this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]'));
this.panels = Array.from(this.container.querySelectorAll(':scope > [role="tabpanel"], :scope > * > [role="tabpanel"]'));
this.activeUnder = this.$opts.activeUnder ? Number(this.$opts.activeUnder) : 10000;
- this.active = null;
this.container.addEventListener('click', event => {
- const tab = event.target.closest('[role="tab"]');
- if (tab && this.tabs.includes(tab)) {
- this.show(tab.getAttribute('aria-controls'));
+ const tab = (event.target as HTMLElement).closest('[role="tab"]');
+ if (tab instanceof HTMLElement && this.tabs.includes(tab)) {
+ this.show(tab.getAttribute('aria-controls') || '');
}
});
@@ -40,7 +51,7 @@ export class Tabs extends Component {
this.updateActiveState();
}
- show(sectionId) {
+ public show(sectionId: string): void {
for (const panel of this.panels) {
panel.toggleAttribute('hidden', panel.id !== sectionId);
}
@@ -51,10 +62,11 @@ export class Tabs extends Component {
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
}
- this.$emit('change', {showing: sectionId});
+ const data: TabsChangeEvent = {showing: sectionId};
+ this.$emit('change', data);
}
- updateActiveState() {
+ protected updateActiveState(): void {
const active = window.innerWidth < this.activeUnder;
if (active === this.active) {
return;
@@ -69,13 +81,13 @@ export class Tabs extends Component {
this.active = active;
}
- activate() {
+ protected activate(): void {
const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0];
this.show(panelToShow.id);
this.tabList.toggleAttribute('hidden', false);
}
- deactivate() {
+ protected deactivate(): void {
for (const panel of this.panels) {
panel.removeAttribute('hidden');
}
diff --git a/resources/js/services/__tests__/translations.test.ts b/resources/js/services/__tests__/translations.test.ts
index 043f1745f..5014051ab 100644
--- a/resources/js/services/__tests__/translations.test.ts
+++ b/resources/js/services/__tests__/translations.test.ts
@@ -58,6 +58,11 @@ describe('Translations Service', () => {
expect(caseB).toEqual('an orange angry big dinosaur');
});
+ test('it provides count as a replacement by default', () => {
+ const caseA = $trans.choice(`:count cats|:count dogs`, 4);
+ expect(caseA).toEqual('4 dogs');
+ });
+
test('not provided replacements are left as-is', () => {
const caseA = $trans.choice(`An :a dog`, 5, {});
expect(caseA).toEqual('An :a dog');
diff --git a/resources/js/services/components.ts b/resources/js/services/components.ts
index c19939e92..0e13cd0a0 100644
--- a/resources/js/services/components.ts
+++ b/resources/js/services/components.ts
@@ -139,8 +139,8 @@ export class ComponentStore {
/**
* Get all the components of the given name.
*/
- public get(name: string): Component[] {
- return this.components[name] || [];
+ public get(name: string): T[] {
+ return (this.components[name] || []) as T[];
}
/**
@@ -150,4 +150,9 @@ export class ComponentStore {
const elComponents = this.elementComponentMap.get(element) || {};
return elComponents[name] || null;
}
+
+ public allWithinElement(element: HTMLElement, name: string): T[] {
+ const components = this.get(name);
+ return components.filter(c => element.contains(c.$el));
+ }
}
diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts
index c88827bac..c3817536c 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
*/
@@ -44,9 +46,11 @@ export function forEach(selector: string, callback: (el: Element) => any) {
/**
* Helper to listen to multiple DOM events
*/
-export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void {
- for (const eventName of events) {
- listenerElement.addEventListener(eventName, callback);
+export function onEvents(listenerElement: Element|null, events: string[], callback: (e: Event) => any): void {
+ if (listenerElement) {
+ for (const eventName of events) {
+ listenerElement.addEventListener(eventName, callback);
+ }
}
}
@@ -178,3 +182,78 @@ 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');
+ }
+
+ 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;
+}
+
+/**
+ * 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 as HTMLElement, offset - currentOffset);
+ }
+
+ currentOffset += elementTextLength;
+ }
+ }
+
+ // Return null if not found within range
+ return null;
+}
+
+/**
+ * Create a hash for the given HTML element content.
+ */
+export function hashElement(element: HTMLElement): string {
+ const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
+ return cyrb53(normalisedElemText);
+}
\ No newline at end of file
diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts
index be9fba7ec..6045d51f8 100644
--- a/resources/js/services/events.ts
+++ b/resources/js/services/events.ts
@@ -1,7 +1,9 @@
import {HttpError} from "./http";
+type Listener = (data: any) => void;
+
export class EventManager {
- protected listeners: Record void)[]> = {};
+ protected listeners: Record = {};
protected stack: {name: string, data: {}}[] = [];
/**
@@ -24,6 +26,17 @@ export class EventManager {
this.listeners[eventName].push(callback);
}
+ /**
+ * Remove an event listener which is using the given callback for the given event name.
+ */
+ remove(eventName: string, callback: Listener): void {
+ const listeners = this.listeners[eventName] || [];
+ const index = listeners.indexOf(callback);
+ if (index !== -1) {
+ listeners.splice(index, 1);
+ }
+ }
+
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
@@ -53,8 +66,7 @@ export class EventManager {
/**
* Notify of standard server-provided validation errors.
*/
- showValidationErrors(responseErr: {status?: number, data?: object}): void {
- if (!responseErr.status) return;
+ showValidationErrors(responseErr: HttpError): void {
if (responseErr.status === 422 && responseErr.data) {
const message = Object.values(responseErr.data).flat().join('\n');
this.error(message);
diff --git a/resources/js/services/translations.ts b/resources/js/services/translations.ts
index b37dbdfb0..f548a51d1 100644
--- a/resources/js/services/translations.ts
+++ b/resources/js/services/translations.ts
@@ -10,6 +10,7 @@ export class Translator {
* to use. Similar format at Laravel's 'trans_choice' helper.
*/
choice(translation: string, count: number, replacements: Record = {}): string {
+ replacements = Object.assign({}, {count: String(count)}, replacements);
const splitText = translation.split('|');
const exactCountRegex = /^{([0-9]+)}/;
const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts
index c5a5d2db8..61a02a3d2 100644
--- a/resources/js/services/util.ts
+++ b/resources/js/services/util.ts
@@ -144,4 +144,25 @@ function getVersion(): string {
export function importVersioned(moduleName: string): Promise