mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-06-11 14:23:31 +08:00
Merge pull request #5584 from BookStackApp/content_comments
Content Comments
This commit is contained in:
@ -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.
|
||||
*/
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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
|
||||
|
23
app/Activity/Tools/CommentTreeNode.php
Normal file
23
app/Activity/Tools/CommentTreeNode.php
Normal 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;
|
||||
}
|
||||
}
|
@ -27,6 +27,8 @@ class CommentFactory extends Factory
|
||||
'html' => $html,
|
||||
'parent_id' => null,
|
||||
'local_id' => 1,
|
||||
'content_ref' => '',
|
||||
'archived' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
@ -30,6 +30,8 @@ return [
|
||||
'create' => 'Create',
|
||||
'update' => 'Update',
|
||||
'edit' => 'Edit',
|
||||
'archive' => 'Archive',
|
||||
'unarchive' => 'Un-Archive',
|
||||
'sort' => 'Sort',
|
||||
'move' => 'Move',
|
||||
'copy' => 'Copy',
|
||||
|
@ -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
|
||||
|
1
resources/icons/archive.svg
Normal file
1
resources/icons/archive.svg
Normal 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 |
1
resources/icons/bookmark.svg
Normal file
1
resources/icons/bookmark.svg
Normal 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 |
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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';
|
||||
|
251
resources/js/components/page-comment-reference.ts
Normal file
251
resources/js/components/page-comment-reference.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
184
resources/js/components/page-comment.ts
Normal file
184
resources/js/components/page-comment.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
260
resources/js/components/page-comments.ts
Normal file
260
resources/js/components/page-comments.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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');
|
||||
}
|
@ -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');
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
|
@ -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*]+)]/;
|
||||
|
@ -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)));
|
||||
}
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
overflow-wrap: break-word;
|
||||
position: relative;
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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']);
|
||||
|
||||
|
134
tests/Entity/CommentDisplayTest.php
Normal file
134
tests/Entity/CommentDisplayTest.php
Normal 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');
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user