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;
/**
* 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();
$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);
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.
*/
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;
$input = $this->validate($request, [
'html' => ['required', 'string'],
'parent_id' => ['nullable', 'integer'],
+ 'content_ref' => ['string'],
]);
$page = $this->pageQueries->findVisibleById($pageId);
// 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, []),
]);
}
]);
}
+ /**
+ * 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.
*/
* @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
{
{
/**
* The built nested tree structure array.
- * @var array{comment: Comment, depth: int, children: array}[]
+ * @var CommentTreeNode[]
*/
protected array $tree;
protected array $comments;
public function empty(): bool
{
- return count($this->tree) === 0;
+ return count($this->getActive()) === 0;
}
public function count(): int
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
/**
* @param Comment[] $comments
+ * @return CommentTreeNode[]
*/
protected function createTree(array $comments): array
{
$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
--- /dev/null
+<?php
+
+namespace BookStack\Activity\Tools;
+
+use BookStack\Activity\Models\Comment;
+
+class CommentTreeNode
+{
+ public Comment $comment;
+ public int $depth;
+
+ /**
+ * @var CommentTreeNode[]
+ */
+ public array $children;
+
+ public function __construct(Comment $comment, int $depth, array $children)
+ {
+ $this->comment = $comment;
+ $this->depth = $depth;
+ $this->children = $children;
+ }
+}
'html' => $html,
'parent_id' => null,
'local_id' => 1,
+ 'content_ref' => '',
+ 'archived' => false,
];
}
}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('comments', function (Blueprint $table) {
+ $table->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');
+ });
+ }
+};
'create' => 'Create',
'update' => 'Update',
'edit' => 'Edit',
+ 'archive' => 'Archive',
+ 'unarchive' => 'Un-Archive',
'sort' => 'Sort',
'move' => 'Move',
'copy' => 'Copy',
'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',
'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
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-640v440h560v-440H200Zm0 520q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Zm264 300Z"/></svg>
\ No newline at end of file
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z"/></svg>
\ No newline at end of file
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');
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);
}
}
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';
--- /dev/null
+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<EditorToolboxChangeEventData>) => {
+ 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<TabsChangeEvent>) => {
+ 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 = <string>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
+++ /dev/null
-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;
- }
-
-}
--- /dev/null
+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<string, Object>).editor_translations,
+ });
+
+ (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
+ this.wysiwygEditor = editors[0];
+ setTimeout(() => this.wysiwygEditor.focus(), 50);
+ });
+ }
+
+ protected async update(event: Event): Promise<void> {
+ 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<void> {
+ 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<PageCommentReference>(branch, 'page-comment-reference');
+ for (const ref of refs) {
+ ref.hideMarker();
+ }
+ branch.remove();
+ }
+
+ window.$events.success(this.deletedText);
+ }
+
+ protected async archive(): Promise<void> {
+ 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<PageCommentReference>(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;
+ }
+}
+++ /dev/null
-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();
- }
-
-}
--- /dev/null
+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<PageCommentReplyEventData>) => {
+ this.setReply(event.detail.id, event.detail.element);
+ }) as EventListener);
+
+ this.elem.addEventListener('page-comment-archive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+ this.archiveContainer.append(event.detail.new_thread_dom);
+ setTimeout(() => this.updateCount(), 1);
+ }) as EventListener);
+
+ this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+ 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<PageCommentReference>(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<string, Object>).editor_translations,
+ });
+
+ (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).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);
+ }
+ };
+ }
+
+}
-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();
}
// Select all contents on input click
DOM.onSelect([this.includeInput, this.linkInput], event => {
- event.target.select();
+ (event.target as HTMLInputElement).select();
event.stopPropagation();
});
// 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();
});
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);
}
});
// 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();
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});
}
/**
* 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}}}`;
// 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');
}
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);
+ }
+
}
import {Component} from './component';
+export interface TabsChangeEvent {
+ showing: string;
+}
+
/**
* Tabs
* Uses accessible attributes to drive its functionality.
*/
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') || '');
}
});
this.updateActiveState();
}
- show(sectionId) {
+ public show(sectionId: string): void {
for (const panel of this.panels) {
panel.toggleAttribute('hidden', panel.id !== sectionId);
}
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;
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');
}
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');
/**
* Get all the components of the given name.
*/
- public get(name: string): Component[] {
- return this.components[name] || [];
+ public get<T extends Component>(name: string): T[] {
+ return (this.components[name] || []) as T[];
}
/**
const elComponents = this.elementComponentMap.get(element) || {};
return elComponents[name] || null;
}
+
+ public allWithinElement<T extends Component>(element: HTMLElement, name: string): T[] {
+ const components = this.get<T>(name);
+ return components.filter(c => element.contains(c.$el));
+ }
}
+import {cyrb53} from "./util";
+
/**
* Check if the given param is a HTMLElement
*/
/**
* 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);
+ }
}
}
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
import {HttpError} from "./http";
+type Listener = (data: any) => void;
+
export class EventManager {
- protected listeners: Record<string, ((data: any) => void)[]> = {};
+ protected listeners: Record<string, Listener[]> = {};
protected stack: {name: string, data: {}}[] = [];
/**
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.
/**
* 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);
* to use. Similar format at Laravel's 'trans_choice' helper.
*/
choice(translation: string, count: number, replacements: Record<string, string> = {}): string {
+ replacements = Object.assign({}, {count: String(count)}, replacements);
const splitText = translation.split('|');
const exactCountRegex = /^{([0-9]+)}/;
const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
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 String((4294967296 * (2097151 & h2) + (h1 >>> 0)));
}
\ No newline at end of file
animation-duration: 180ms;
animation-delay: 0s;
animation-timing-function: cubic-bezier(.62, .28, .23, .99);
+}
+
+@keyframes highlight {
+ 0% {
+ background-color: var(--color-primary-light);
+ }
+ 33% {
+ background-color: transparent;
+ }
+ 66% {
+ background-color: var(--color-primary-light);
+ }
+ 100% {
+ background-color: transparent;
+ }
+}
+
+.anim-highlight {
+ animation-name: highlight;
+ animation-duration: 2s;
+ animation-delay: 0s;
+ animation-timing-function: linear;
}
\ No newline at end of file
border-bottom: 0;
padding: 0 vars.$xs;
}
+.tab-container [role="tabpanel"].no-outline:focus {
+ outline: none;
+}
.image-picker .none {
display: none;
height: calc(100% - vars.$m);
}
+.comment-reference-indicator-wrap a {
+ float: left;
+ margin-top: vars.$xs;
+ font-size: 12px;
+ display: inline-block;
+ font-weight: bold;
+ position: relative;
+ border-radius: 4px;
+ overflow: hidden;
+ padding: 2px 6px 2px 0;
+ margin-inline-end: vars.$xs;
+ color: var(--color-link);
+ span {
+ display: none;
+ }
+ &.outdated span {
+ display: inline;
+ }
+ &.outdated.missing {
+ color: var(--color-warning);
+ pointer-events: none;
+ }
+ svg {
+ width: 24px;
+ margin-inline-end: 0;
+ }
+ &:after {
+ background-color: currentColor;
+ content: '';
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ left: 0;
+ top: 0;
+ opacity: 0.15;
+ }
+ &[href="#"] {
+ color: #444;
+ pointer-events: none;
+ }
+}
+
+.comment-branch .comment-box {
+ margin-bottom: vars.$m;
+}
+
.comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator {
display: none;
}
display: block;
}
+.comment-container .empty-state {
+ display: none;
+}
+.comment-container:not(:has([component="page-comment"])) .empty-state {
+ display: block;
+}
+
.comment-container-compact .comment-box {
+ margin-bottom: vars.$xs;
.meta {
font-size: 0.8rem;
}
width: vars.$m;
}
+.comment-container-super-compact .comment-box {
+ .meta {
+ font-size: 12px;
+ }
+ .avatar {
+ width: 22px;
+ height: 22px;
+ margin-inline-end: 2px !important;
+ }
+ .content {
+ padding: vars.$xxs vars.$s;
+ line-height: 1.2;
+ }
+ .content p {
+ font-size: 12px;
+ }
+}
+
+.comment-container-super-compact .comment-thread-indicator {
+ width: (vars.$xs + 3px);
+ margin-inline-start: 3px;
+}
+
#tag-manager .drag-card {
max-width: 500px;
}
}
.scroll-box > li.empty-state:last-child {
display: list-item;
+}
+
+details.section-expander summary {
+ border-top: 1px solid #DDD;
+ @include mixins.lightDark(border-color, #DDD, #000);
+ font-weight: bold;
+ font-size: 12px;
+ color: #888;
+ cursor: pointer;
+ padding-block: vars.$xs;
+}
+details.section-expander:open summary {
+ margin-bottom: vars.$s;
+}
+details.section-expander {
+ border-bottom: 1px solid #DDD;
+ @include mixins.lightDark(border-color, #DDD, #000);
}
\ No newline at end of file
max-width: 840px;
margin: 0 auto;
overflow-wrap: break-word;
+ position: relative;
.align-left {
text-align: left;
}
border-radius: 4px;
box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1);
@include mixins.lightDark(background-color, #fff, #333);
- width: 275px;
-
- &.is-page-editable {
- width: 328px;
- }
+ width: 328px;
&:before {
position: absolute;
}
input, button, a {
position: relative;
- border-radius: 0;
height: 28px;
font-size: 12px;
vertical-align: top;
border: 1px solid #DDD;
@include mixins.lightDark(border-color, #ddd, #000);
color: #666;
- width: 160px;
- z-index: 40;
- padding: 5px 10px;
+ width: auto;
+ flex: 1;
+ 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-inline-start: -1px;
+ margin-block: 0;
box-shadow: none;
+ border-radius: 0;
}
a.button {
margin: 0;
}
}
+// 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;
+ }
+}
+.content-comment-window {
+ font-size: vars.$fs-m;
+ line-height: 1.4;
+ position: absolute;
+ top: calc(100% + 3px);
+ left: 0;
+ z-index: 92;
+ pointer-events: all;
+ min-width: min(340px, 80vw);
+ @include mixins.lightDark(background-color, #FFF, #222);
+ box-shadow: vars.$bs-hover;
+ border-radius: 4px;
+ overflow: hidden;
+}
+.content-comment-window-actions {
+ background-color: var(--color-primary);
+ color: #FFF;
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ gap: vars.$xs;
+ button {
+ color: #FFF;
+ font-size: 12px;
+ padding: vars.$xs;
+ line-height: 1;
+ cursor: pointer;
+ }
+ button[data-action="jump"] {
+ text-decoration: underline;
+ }
+ svg {
+ fill: currentColor;
+ width: 12px;
+ }
+}
+.content-comment-window-content {
+ padding: vars.$xs vars.$s vars.$xs vars.$xs;
+ max-height: 200px;
+ overflow-y: scroll;
+}
+.content-comment-window-content .comment-reference-indicator-wrap {
+ display: none;
+}
+.content-comment-marker {
+ position: absolute;
+ right: -16px;
+ top: -16px;
+ pointer-events: all;
+ width: min(1.5em, 32px);
+ height: min(1.5em, 32px);
+ border-radius: min(calc(1.5em / 2), 32px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--color-primary);
+ box-shadow: vars.$bs-hover;
+ color: #FFF;
+ cursor: pointer;
+ z-index: 90;
+ transform: scale(1);
+ transition: transform ease-in-out 120ms;
+ svg {
+ fill: #FFF;
+ width: 80%;
+ }
+}
+.page-content [id^="bkmrk-"]:hover .content-comment-marker {
+ transform: scale(1.15);
+}
+
// Page editor sidebar toolbox
.floating-toolbox {
@include mixins.lightDark(background-color, #FFF, #222);
+{{--
+$branch CommentTreeNode
+--}}
<div class="comment-branch">
- <div class="mb-m">
- @include('comments.comment', ['comment' => $branch['comment']])
+ <div>
+ @include('comments.comment', ['comment' => $branch->comment])
</div>
<div class="flex-container-row">
<div class="comment-thread-indicator-parent">
<div class="comment-thread-indicator"></div>
</div>
<div class="comment-branch-children flex">
- @foreach($branch['children'] as $childBranch)
+ @foreach($branch->children as $childBranch)
@include('comments.comment-branch', ['branch' => $childBranch])
@endforeach
</div>
<div component="{{ $readOnly ? '' : 'page-comment' }}"
option:page-comment:comment-id="{{ $comment->id }}"
option:page-comment:comment-local-id="{{ $comment->local_id }}"
- option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}"
+ option:page-comment:archive-text="{{ $comment->archived ? trans('entities.comment_unarchive_success') : trans('entities.comment_archive_success') }}"
option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}"
option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
id="comment{{$comment->local_id}}"
@if(userCan('comment-create-all'))
<button refs="page-comment@reply-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('reply') {{ trans('common.reply') }}</button>
@endif
+ @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment)))
+ <button refs="page-comment@archive-button"
+ type="button"
+ data-is-archived="{{ $comment->archived ? 'true' : 'false' }}"
+ class="text-button text-muted hover-underline text-small p-xs">@icon('archive') {{ trans('common.' . ($comment->archived ? 'unarchive' : 'archive')) }}</button>
+ @endif
@if(userCan('comment-update', $comment))
<button refs="page-comment@edit-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('edit') {{ trans('common.edit') }}</button>
@endif
<a class="text-muted text-small" href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a>
</p>
@endif
+ @if($comment->content_ref)
+ <div class="comment-reference-indicator-wrap">
+ <a component="page-comment-reference"
+ option:page-comment-reference:reference="{{ $comment->content_ref }}"
+ option:page-comment-reference:view-comment-text="{{ trans('entities.comment_view') }}"
+ option:page-comment-reference:jump-to-thread-text="{{ trans('entities.comment_jump_to_thread') }}"
+ option:page-comment-reference:close-text="{{ trans('common.close') }}"
+ href="#">@icon('bookmark'){{ trans('entities.comment_reference') }} <span>{{ trans('entities.comment_reference_outdated') }}</span></a>
+ </div>
+ @endif
{!! $commentHtml !!}
</div>
-<section component="page-comments"
+<section components="page-comments tabs"
option:page-comments:page-id="{{ $page->id }}"
option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
- option:page-comments:count-text="{{ trans('entities.comment_count') }}"
+ option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}"
+ option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}"
option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}"
option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
- class="comments-list"
+ class="comments-list tab-container"
aria-label="{{ trans('entities.comments') }}">
- <div refs="page-comments@comment-count-bar" class="grid half left-focus v-center no-row-gap">
- <h5 refs="page-comments@comments-title">{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}</h5>
+ <div refs="page-comments@comment-count-bar" class="flex-container-row items-center">
+ <div role="tablist" class="flex">
+ <button type="button"
+ role="tab"
+ id="comment-tab-active"
+ aria-controls="comment-tab-panel-active"
+ refs="page-comments@active-tab"
+ aria-selected="true">{{ trans_choice('entities.comment_thread_count', $commentTree->activeThreadCount()) }}</button>
+ <button type="button"
+ role="tab"
+ id="comment-tab-archived"
+ aria-controls="comment-tab-panel-archived"
+ refs="page-comments@archived-tab"
+ aria-selected="false">{{ trans_choice('entities.comment_archived_count', count($commentTree->getArchived())) }}</button>
+ </div>
@if ($commentTree->empty() && userCan('comment-create-all'))
- <div class="text-m-right" refs="page-comments@add-button-container">
+ <div class="ml-m" refs="page-comments@add-button-container">
<button type="button"
refs="page-comments@add-comment-button"
- class="button outline">{{ trans('entities.comment_add') }}</button>
+ class="button outline mb-m">{{ trans('entities.comment_add') }}</button>
</div>
@endif
</div>
- <div refs="page-comments@commentContainer" class="comment-container">
- @foreach($commentTree->get() as $branch)
+ <div id="comment-tab-panel-active"
+ tabindex="0"
+ role="tabpanel"
+ aria-labelledby="comment-tab-active"
+ class="comment-container no-outline">
+ <div refs="page-comments@comment-container">
+ @foreach($commentTree->getActive() as $branch)
+ @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
+ @endforeach
+ </div>
+
+ <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
+
+ @if(userCan('comment-create-all'))
+ @include('comments.create')
+ @if (!$commentTree->empty())
+ <div refs="page-comments@addButtonContainer" class="flex-container-row">
+ <button type="button"
+ refs="page-comments@add-comment-button"
+ class="button outline ml-auto">{{ trans('entities.comment_add') }}</button>
+ </div>
+ @endif
+ @endif
+ </div>
+
+ <div refs="page-comments@archive-container"
+ id="comment-tab-panel-archived"
+ tabindex="0"
+ role="tabpanel"
+ aria-labelledby="comment-tab-archived"
+ hidden="hidden"
+ class="comment-container no-outline">
+ @foreach($commentTree->getArchived() as $branch)
@include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
@endforeach
+ <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
</div>
- @if(userCan('comment-create-all'))
- @include('comments.create')
- @if (!$commentTree->empty())
- <div refs="page-comments@addButtonContainer" class="text-right">
- <button type="button"
- refs="page-comments@add-comment-button"
- class="button outline">{{ trans('entities.comment_add') }}</button>
- </div>
- @endif
- @endif
-
@if(userCan('comment-create-all') || $commentTree->canUpdateAny())
@push('body-end')
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>
</div>
</div>
</div>
+ <div refs="page-comments@reference-row" hidden class="primary-background-light text-muted px-s py-xs">
+ <div class="grid left-focus v-center">
+ <div>
+ <a refs="page-comments@formReferenceLink" href="#">{{ trans('entities.comment_reference') }}</a>
+ </div>
+ <div class="text-right">
+ <button refs="page-comments@remove-reference-button" class="text-button">{{ trans('common.remove') }}</button>
+ </div>
+ </div>
+ </div>
<div class="content px-s pt-s">
<form refs="page-comments@form" novalidate>
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" >
+ <div refs="pointer@mode-section" class="flex 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">
+ <div class="input-group flex flex-container-row items-center">
<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">
+ <div refs="pointer@mode-section" hidden class="flex flex-container-row items-center gap-xs">
<button refs="pointer@mode-toggle"
title="{{ trans('entities.pages_pointer_toggle_include') }}"
class="text-button icon px-xs">@icon('include')</button>
- <div class="input-group">
+ <div class="input-group flex flex-container-row items-center">
<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>
+{{--
+$comments - CommentTree
+--}}
<div refs="editor-toolbox@tab-content" data-tab-content="comments" class="toolbox-tab-content">
<h4>{{ trans('entities.comments') }}</h4>
<p class="text-muted small mb-m">
{{ trans('entities.comment_editor_explain') }}
</p>
- @foreach($comments->get() as $branch)
+ @foreach($comments->getActive() as $branch)
@include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
@endforeach
@if($comments->empty())
- <p class="italic text-muted">{{ trans('common.no_items') }}</p>
+ <p class="italic text-muted">{{ trans('entities.comment_none') }}</p>
+ @endif
+ @if($comments->archivedThreadCount() > 0)
+ <details class="section-expander mt-s">
+ <summary>{{ trans('entities.comment_archived_threads') }}</summary>
+ @foreach($comments->getArchived() as $branch)
+ @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
+ @endforeach
+ </details>
@endif
</div>
</div>
\ No newline at end of file
@include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous])
@if ($commentTree->enabled())
- @if(($previous || $next))
- <div class="px-xl print-hidden">
- <hr class="darker">
- </div>
- @endif
-
<div class="comments-container mb-l print-hidden">
@include('comments.comments', ['commentTree' => $commentTree, 'page' => $page])
<div class="clearfix"></div>
// Comments
Route::post('/comment/{pageId}', [ActivityControllers\CommentController::class, 'savePageComment']);
+ Route::put('/comment/{id}/archive', [ActivityControllers\CommentController::class, 'archive']);
+ Route::put('/comment/{id}/unarchive', [ActivityControllers\CommentController::class, 'unarchive']);
Route::put('/comment/{id}', [ActivityControllers\CommentController::class, 'update']);
Route::delete('/comment/{id}', [ActivityControllers\CommentController::class, 'destroy']);
--- /dev/null
+<?php
+
+namespace Entity;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class CommentDisplayTest extends TestCase
+{
+ public function test_reply_comments_are_nested()
+ {
+ $this->asAdmin();
+ $page = $this->entities->page();
+
+ $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+ $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl()));
+ $respHtml->assertElementCount('.comment-branch', 3);
+ $respHtml->assertElementNotExists('.comment-branch .comment-branch');
+
+ $comment = $page->comments()->first();
+ $resp = $this->postJson("/comment/$page->id", [
+ 'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
+ ]);
+ $resp->assertStatus(200);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl()));
+ $respHtml->assertElementCount('.comment-branch', 4);
+ $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
+ }
+
+ public function test_comments_are_visible_in_the_page_editor()
+ {
+ $page = $this->entities->page();
+
+ $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
+ $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
+ }
+
+ public function test_comment_creator_name_truncated()
+ {
+ [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
+
+ $pageResp = $this->asAdmin()->get($page->getUrl());
+ $pageResp->assertSee('Wolfeschlegels…');
+ }
+
+ public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
+ {
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $resp = $this->actingAs($editor)->get($page->getUrl());
+ $resp->assertSee('tinymce.min.js?', false);
+ $resp->assertSee('window.editor_translations', false);
+ $resp->assertSee('component="entity-selector"', false);
+
+ $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
+ $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
+
+ $resp = $this->actingAs($editor)->get($page->getUrl());
+ $resp->assertDontSee('tinymce.min.js?', false);
+ $resp->assertDontSee('window.editor_translations', false);
+ $resp->assertDontSee('component="entity-selector"', false);
+
+ Comment::factory()->create([
+ 'created_by' => $editor->id,
+ 'entity_type' => 'page',
+ 'entity_id' => $page->id,
+ ]);
+
+ $resp = $this->actingAs($editor)->get($page->getUrl());
+ $resp->assertSee('tinymce.min.js?', false);
+ $resp->assertSee('window.editor_translations', false);
+ $resp->assertSee('component="entity-selector"', false);
+ }
+
+ public function test_comment_displays_relative_times()
+ {
+ $page = $this->entities->page();
+ $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
+ $comment->created_at = now()->subWeek();
+ $comment->updated_at = now()->subDay();
+ $comment->save();
+
+ $pageResp = $this->asAdmin()->get($page->getUrl());
+ $html = $this->withHtml($pageResp);
+
+ // Create date shows relative time as text to user
+ $html->assertElementContains('.comment-box', 'commented 1 week ago');
+ // Updated indicator has full time as title
+ $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') . '"]', 'Updated');
+ }
+
+ public function test_comment_displays_reference_if_set()
+ {
+ $page = $this->entities->page();
+ $comment = Comment::factory()->make([
+ 'content_ref' => 'bkmrk-a:abc:4-1',
+ 'local_id' => 10,
+ ]);
+ $page->comments()->save($comment);
+
+ $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+ $html->assertElementExists('#comment10 .comment-reference-indicator-wrap a');
+ }
+
+ public function test_archived_comments_are_shown_in_their_own_container()
+ {
+ $page = $this->entities->page();
+ $comment = Comment::factory()->make(['local_id' => 44]);
+ $page->comments()->save($comment);
+
+ $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+ $html->assertElementExists('#comment-tab-panel-active #comment44');
+ $html->assertElementNotExists('#comment-tab-panel-archived .comment-box');
+
+ $comment->archived = true;
+ $comment->save();
+
+ $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+ $html->assertElementExists('#comment-tab-panel-archived #comment44.comment-box');
+ $html->assertElementNotExists('#comment-tab-panel-active #comment44');
+ }
+}
use BookStack\Entities\Models\Page;
use Tests\TestCase;
-class CommentTest extends TestCase
+class CommentStoreTest extends TestCase
{
public function test_add_comment()
{
$this->assertActivityExists(ActivityType::COMMENT_CREATE);
}
+ public function test_add_comment_stores_content_reference_only_if_format_valid()
+ {
+ $validityByRefs = [
+ 'bkmrk-my-title:4589284922:4-3' => true,
+ 'bkmrk-my-title:4589284922:' => true,
+ 'bkmrk-my-title:4589284922:abc' => false,
+ 'my-title:4589284922:' => false,
+ 'bkmrk-my-title-4589284922:' => false,
+ ];
+
+ $page = $this->entities->page();
+
+ foreach ($validityByRefs as $ref => $valid) {
+ $this->asAdmin()->postJson("/comment/$page->id", [
+ 'html' => '<p>My comment</p>',
+ 'parent_id' => null,
+ 'content_ref' => $ref,
+ ]);
+
+ if ($valid) {
+ $this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
+ } else {
+ $this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
+ }
+ }
+ }
public function test_comment_edit()
{
$this->assertActivityExists(ActivityType::COMMENT_DELETE);
}
+ public function test_comment_archive_and_unarchive()
+ {
+ $this->asAdmin();
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $page->comments()->save($comment);
+ $comment->refresh();
+
+ $this->put("/comment/$comment->id/archive");
+
+ $this->assertDatabaseHas('comments', [
+ 'id' => $comment->id,
+ 'archived' => true,
+ ]);
+
+ $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+
+ $this->put("/comment/$comment->id/unarchive");
+
+ $this->assertDatabaseHas('comments', [
+ 'id' => $comment->id,
+ 'archived' => false,
+ ]);
+
+ $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+ }
+
+ public function test_archive_endpoints_require_delete_or_edit_permissions()
+ {
+ $viewer = $this->users->viewer();
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $page->comments()->save($comment);
+ $comment->refresh();
+
+ $endpoints = ["/comment/$comment->id/archive", "/comment/$comment->id/unarchive"];
+
+ foreach ($endpoints as $endpoint) {
+ $resp = $this->actingAs($viewer)->put($endpoint);
+ $this->assertPermissionError($resp);
+ }
+
+ $this->permissions->grantUserRolePermissions($viewer, ['comment-delete-all']);
+
+ foreach ($endpoints as $endpoint) {
+ $resp = $this->actingAs($viewer)->put($endpoint);
+ $resp->assertOk();
+ }
+
+ $this->permissions->removeUserRolePermissions($viewer, ['comment-delete-all']);
+ $this->permissions->grantUserRolePermissions($viewer, ['comment-update-all']);
+
+ foreach ($endpoints as $endpoint) {
+ $resp = $this->actingAs($viewer)->put($endpoint);
+ $resp->assertOk();
+ }
+ }
+
+ public function test_non_top_level_comments_cant_be_archived_or_unarchived()
+ {
+ $this->asAdmin();
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $page->comments()->save($comment);
+ $subComment = Comment::factory()->make(['parent_id' => $comment->id]);
+ $page->comments()->save($subComment);
+ $subComment->refresh();
+
+ $resp = $this->putJson("/comment/$subComment->id/archive");
+ $resp->assertStatus(400);
+
+ $this->assertDatabaseHas('comments', [
+ 'id' => $subComment->id,
+ 'archived' => false,
+ ]);
+
+ $resp = $this->putJson("/comment/$subComment->id/unarchive");
+ $resp->assertStatus(400);
+ }
+
public function test_scripts_cannot_be_injected_via_comment_html()
{
$page = $this->entities->page();
'html' => $expected,
]);
}
-
- public function test_reply_comments_are_nested()
- {
- $this->asAdmin();
- $page = $this->entities->page();
-
- $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
- $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
-
- $respHtml = $this->withHtml($this->get($page->getUrl()));
- $respHtml->assertElementCount('.comment-branch', 3);
- $respHtml->assertElementNotExists('.comment-branch .comment-branch');
-
- $comment = $page->comments()->first();
- $resp = $this->postJson("/comment/$page->id", [
- 'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
- ]);
- $resp->assertStatus(200);
-
- $respHtml = $this->withHtml($this->get($page->getUrl()));
- $respHtml->assertElementCount('.comment-branch', 4);
- $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
- }
-
- public function test_comments_are_visible_in_the_page_editor()
- {
- $page = $this->entities->page();
-
- $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
-
- $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
- $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
- }
-
- public function test_comment_creator_name_truncated()
- {
- [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
- $page = $this->entities->page();
-
- $comment = Comment::factory()->make();
- $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
-
- $pageResp = $this->asAdmin()->get($page->getUrl());
- $pageResp->assertSee('Wolfeschlegels…');
- }
-
- public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
- {
- $editor = $this->users->editor();
- $page = $this->entities->page();
-
- $resp = $this->actingAs($editor)->get($page->getUrl());
- $resp->assertSee('tinymce.min.js?', false);
- $resp->assertSee('window.editor_translations', false);
- $resp->assertSee('component="entity-selector"', false);
-
- $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
- $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
-
- $resp = $this->actingAs($editor)->get($page->getUrl());
- $resp->assertDontSee('tinymce.min.js?', false);
- $resp->assertDontSee('window.editor_translations', false);
- $resp->assertDontSee('component="entity-selector"', false);
-
- Comment::factory()->create([
- 'created_by' => $editor->id,
- 'entity_type' => 'page',
- 'entity_id' => $page->id,
- ]);
-
- $resp = $this->actingAs($editor)->get($page->getUrl());
- $resp->assertSee('tinymce.min.js?', false);
- $resp->assertSee('window.editor_translations', false);
- $resp->assertSee('component="entity-selector"', false);
- }
-
- public function test_comment_displays_relative_times()
- {
- $page = $this->entities->page();
- $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
- $comment->created_at = now()->subWeek();
- $comment->updated_at = now()->subDay();
- $comment->save();
-
- $pageResp = $this->asAdmin()->get($page->getUrl());
- $html = $this->withHtml($pageResp);
-
- // Create date shows relative time as text to user
- $html->assertElementContains('.comment-box', 'commented 1 week ago');
- // Updated indicator has full time as title
- $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') . '"]', 'Updated');
- }
}