Merge pull request #5584 from BookStackApp/content_comments

Content Comments
This commit is contained in:
Dan Brown
2025-05-22 16:58:36 +01:00
committed by GitHub
40 changed files with 1743 additions and 520 deletions

View File

@ -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.
*/

View File

@ -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.
*/

View File

@ -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
{

View File

@ -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

View File

@ -0,0 +1,23 @@
<?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;
}
}

View File

@ -27,6 +27,8 @@ class CommentFactory extends Factory
'html' => $html,
'parent_id' => null,
'local_id' => 1,
'content_ref' => '',
'archived' => false,
];
}
}

View File

@ -0,0 +1,30 @@
<?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');
});
}
};

View File

@ -30,6 +30,8 @@ return [
'create' => 'Create',
'update' => 'Update',
'edit' => 'Edit',
'archive' => 'Archive',
'unarchive' => 'Un-Archive',
'sort' => 'Sort',
'move' => 'Move',
'copy' => 'Copy',

View File

@ -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

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 217 B

View File

@ -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);
}
}

View File

@ -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';

View File

@ -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<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`;
}
}
}

View File

@ -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;
}
}

View File

@ -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<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;
}
}

View File

@ -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();
}
}

View File

@ -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<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);
}
};
}
}

View File

@ -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);
}
}

View File

@ -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');
}

View File

@ -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');

View File

@ -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<T extends Component>(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<T extends Component>(element: HTMLElement, name: string): T[] {
const components = this.get<T>(name);
return components.filter(c => element.contains(c.$el));
}
}

View File

@ -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);
}

View File

@ -1,7 +1,9 @@
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: {}}[] = [];
/**
@ -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);

View File

@ -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, string> = {}): string {
replacements = Object.assign({}, {count: String(count)}, replacements);
const splitText = translation.split('|');
const exactCountRegex = /^{([0-9]+)}/;
const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;

View File

@ -144,4 +144,25 @@ function getVersion(): string {
export function importVersioned(moduleName: string): Promise<object> {
const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`);
return import(importPath);
}
/*
cyrb53 (c) 2018 bryc (github.com/bryc)
License: Public domain (or MIT if needed). Attribution appreciated.
A fast and simple 53-bit string hash function with decent collision resistance.
Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
Taken from: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
*/
export function cyrb53(str: string, seed: number = 0): string {
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
for(let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return String((4294967296 * (2097151 & h2) + (h1 >>> 0)));
}

View File

@ -67,4 +67,26 @@
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;
}

View File

@ -569,6 +569,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
border-bottom: 0;
padding: 0 vars.$xs;
}
.tab-container [role="tabpanel"].no-outline:focus {
outline: none;
}
.image-picker .none {
display: none;
@ -746,6 +749,52 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
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;
}
@ -760,7 +809,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
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;
}
@ -778,6 +835,29 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
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;
}
@ -1127,4 +1207,21 @@ input.scroll-box-search, .scroll-box-header-item {
}
.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);
}

View File

@ -11,6 +11,7 @@
max-width: 840px;
margin: 0 auto;
overflow-wrap: break-word;
position: relative;
.align-left {
text-align: left;
}

View File

@ -158,11 +158,7 @@ body.tox-fullscreen, body.markdown-fullscreen {
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;
@ -183,7 +179,6 @@ body.tox-fullscreen, body.markdown-fullscreen {
}
input, button, a {
position: relative;
border-radius: 0;
height: 28px;
font-size: 12px;
vertical-align: top;
@ -194,17 +189,21 @@ body.tox-fullscreen, body.markdown-fullscreen {
border: 1px solid #DDD;
@include mixins.lightDark(border-color, #ddd, #000);
color: #666;
width: 160px;
z-index: 40;
padding: 5px 10px;
width: 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;
@ -218,6 +217,97 @@ body.tox-fullscreen, body.markdown-fullscreen {
}
}
// Page inline comments
.content-comment-highlight {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
user-select: none;
pointer-events: none;
&:after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--color-primary);
opacity: 0.25;
}
}
.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);

View File

@ -1,13 +1,16 @@
{{--
$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>

View File

@ -4,9 +4,9 @@
<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}}"
@ -38,6 +38,12 @@
@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
@ -74,6 +80,16 @@
<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>

View File

@ -1,40 +1,75 @@
<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>

View File

@ -12,6 +12,16 @@
</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>

View File

@ -6,29 +6,36 @@
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>

View File

@ -1,3 +1,6 @@
{{--
$comments - CommentTree
--}}
<div refs="editor-toolbox@tab-content" data-tab-content="comments" class="toolbox-tab-content">
<h4>{{ trans('entities.comments') }}</h4>
@ -5,11 +8,19 @@
<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>

View File

@ -28,12 +28,6 @@
@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>

View File

@ -179,6 +179,8 @@ Route::middleware('auth')->group(function () {
// 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']);

View File

@ -0,0 +1,134 @@
<?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');
}
}

View File

@ -7,7 +7,7 @@ use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Page;
use Tests\TestCase;
class CommentTest extends TestCase
class CommentStoreTest extends TestCase
{
public function test_add_comment()
{
@ -33,6 +33,32 @@ class CommentTest extends TestCase
$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()
{
@ -80,6 +106,89 @@ class CommentTest extends TestCase
$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();
@ -139,96 +248,4 @@ class CommentTest extends TestCase
'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');
}
}