use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Exceptions\PrettyException;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter;
/**
* Create a new comment on an entity.
*/
- public function create(Entity $entity, string $html, ?int $parent_id): Comment
+ public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
{
$userId = user()->id;
$comment = new Comment();
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
- $comment->parent_id = $parent_id;
+ $comment->parent_id = $parentId;
+ $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
$entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
return $comment;
}
+
+ /**
+ * Archive an existing comment.
+ */
+ public function archive(Comment $comment): Comment
+ {
+ if ($comment->parent_id) {
+ throw new NotifyException('Only top-level comments can be archived.', '/', 400);
+ }
+
+ $comment->archived = true;
+ $comment->save();
+
+ ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+ return $comment;
+ }
+
+ /**
+ * Un-archive an existing comment.
+ */
+ public function unarchive(Comment $comment): Comment
+ {
+ if ($comment->parent_id) {
+ throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
+ }
+
+ $comment->archived = false;
+ $comment->save();
+
+ ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+ return $comment;
+ }
+
/**
* Delete a comment from the system.
*/
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
+use BookStack\Activity\Tools\CommentTree;
+use BookStack\Activity\Tools\CommentTreeNode;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
$input = $this->validate($request, [
'html' => ['required', 'string'],
'parent_id' => ['nullable', 'integer'],
+ 'content_ref' => ['string'],
]);
$page = $this->pageQueries->findVisibleById($pageId);
// Create a new comment.
$this->checkPermission('comment-create-all');
- $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
+ $contentRef = $input['content_ref'] ?? '';
+ $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
return view('comments.comment-branch', [
'readOnly' => false,
- 'branch' => [
- 'comment' => $comment,
- 'children' => [],
- ]
+ 'branch' => new CommentTreeNode($comment, 0, []),
]);
}
]);
}
+ /**
+ * Mark a comment as archived.
+ */
+ public function archive(int $id)
+ {
+ $comment = $this->commentRepo->getById($id);
+ $this->checkOwnablePermission('page-view', $comment->entity);
+ if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+ $this->showPermissionError();
+ }
+
+ $this->commentRepo->archive($comment);
+
+ $tree = new CommentTree($comment->entity);
+ return view('comments.comment-branch', [
+ 'readOnly' => false,
+ 'branch' => $tree->getCommentNodeForId($id),
+ ]);
+ }
+
+ /**
+ * Unmark a comment as archived.
+ */
+ public function unarchive(int $id)
+ {
+ $comment = $this->commentRepo->getById($id);
+ $this->checkOwnablePermission('page-view', $comment->entity);
+ if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+ $this->showPermissionError();
+ }
+
+ $this->commentRepo->unarchive($comment);
+
+ $tree = new CommentTree($comment->entity);
+ return view('comments.comment-branch', [
+ 'readOnly' => false,
+ 'branch' => $tree->getCommentNodeForId($id),
+ ]);
+ }
+
/**
* Delete a comment from the system.
*/
* @property int $entity_id
* @property int $created_by
* @property int $updated_by
+ * @property string $content_ref
+ * @property bool $archived
*/
class Comment extends Model implements Loggable
{
{
/**
* The built nested tree structure array.
- * @var array{comment: Comment, depth: int, children: array}[]
+ * @var CommentTreeNode[]
*/
protected array $tree;
protected array $comments;
public function empty(): bool
{
- return count($this->tree) === 0;
+ return count($this->getActive()) === 0;
}
public function count(): int
return count($this->comments);
}
- public function get(): array
+ public function getActive(): array
{
- return $this->tree;
+ return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
+ }
+
+ public function activeThreadCount(): int
+ {
+ return count($this->getActive());
+ }
+
+ public function getArchived(): array
+ {
+ return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
+ }
+
+ public function archivedThreadCount(): int
+ {
+ return count($this->getArchived());
+ }
+
+ public function getCommentNodeForId(int $commentId): ?CommentTreeNode
+ {
+ foreach ($this->tree as $node) {
+ if ($node->comment->id === $commentId) {
+ return $node;
+ }
+ }
+
+ return null;
}
public function canUpdateAny(): bool
/**
* @param Comment[] $comments
+ * @return CommentTreeNode[]
*/
protected function createTree(array $comments): array
{
$tree = [];
foreach ($childMap[0] ?? [] as $childId) {
- $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
+ $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
}
return $tree;
}
- protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
+ protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
{
$childIds = $childMap[$id] ?? [];
$children = [];
foreach ($childIds as $childId) {
- $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
+ $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
}
- return [
- 'comment' => $byId[$id],
- 'depth' => $depth,
- 'children' => $children,
- ];
+ return new CommentTreeNode($byId[$id], $depth, $children);
}
protected function loadComments(): array
--- /dev/null
+<?php
+
+namespace BookStack\Activity\Tools;
+
+use BookStack\Activity\Models\Comment;
+
+class CommentTreeNode
+{
+ public Comment $comment;
+ public int $depth;
+
+ /**
+ * @var CommentTreeNode[]
+ */
+ public array $children;
+
+ public function __construct(Comment $comment, int $depth, array $children)
+ {
+ $this->comment = $comment;
+ $this->depth = $depth;
+ $this->children = $children;
+ }
+}
'root' => public_path(),
'serve' => false,
'throw' => true,
+ 'directory_visibility' => 'public',
],
'local_secure_attachments' => [
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Log;
use League\Flysystem\UnableToSetVisibility;
+use League\Flysystem\Visibility;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ImageStorageDisk
// require different ACLs for S3, and this provides us more logical control.
if ($makePublic && !$this->isS3Like()) {
try {
- $this->filesystem->setVisibility($path, 'public');
+ $this->filesystem->setVisibility($path, Visibility::PUBLIC);
} catch (UnableToSetVisibility $e) {
Log::warning("Unable to set visibility for image upload with relative path: {$path}");
}
},
{
"name": "aws/aws-sdk-php",
- "version": "3.343.6",
+ "version": "3.343.13",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "3746aca8cbed5f46beba850e0a480ef58e71b197"
+ "reference": "eb50d111a09ef39675358e74801260ac129ee346"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3746aca8cbed5f46beba850e0a480ef58e71b197",
- "reference": "3746aca8cbed5f46beba850e0a480ef58e71b197",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/eb50d111a09ef39675358e74801260ac129ee346",
+ "reference": "eb50d111a09ef39675358e74801260ac129ee346",
"shasum": ""
},
"require": {
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.343.6"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.343.13"
},
- "time": "2025-05-07T18:10:08+00:00"
+ "time": "2025-05-16T18:24:39+00:00"
},
{
"name": "bacon/bacon-qr-code",
},
{
"name": "phpstan/phpstan",
- "version": "2.1.14",
+ "version": "2.1.16",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
- "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2"
+ "reference": "b8c1cf533cba0c305d91c6ccd23f3dd0566ba5f9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8f2e03099cac24ff3b379864d171c5acbfc6b9a2",
- "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b8c1cf533cba0c305d91c6ccd23f3dd0566ba5f9",
+ "reference": "b8c1cf533cba0c305d91c6ccd23f3dd0566ba5f9",
"shasum": ""
},
"require": {
"type": "github"
}
],
- "time": "2025-05-02T15:32:28+00:00"
+ "time": "2025-05-16T09:40:10+00:00"
},
{
"name": "phpunit/php-code-coverage",
},
{
"name": "phpunit/phpunit",
- "version": "11.5.19",
+ "version": "11.5.20",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5"
+ "reference": "e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5",
- "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f",
+ "reference": "e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f",
"shasum": ""
},
"require": {
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.19"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.20"
},
"funding": [
{
"type": "tidelift"
}
],
- "time": "2025-05-02T06:56:52+00:00"
+ "time": "2025-05-11T06:39:52+00:00"
},
{
"name": "sebastian/cli-parser",
},
{
"name": "squizlabs/php_codesniffer",
- "version": "3.12.2",
+ "version": "3.13.0",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
- "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa"
+ "reference": "65ff2489553b83b4597e89c3b8b721487011d186"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa",
- "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/65ff2489553b83b4597e89c3b8b721487011d186",
+ "reference": "65ff2489553b83b4597e89c3b8b721487011d186",
"shasum": ""
},
"require": {
"type": "thanks_dev"
}
],
- "time": "2025-04-13T04:10:18+00:00"
+ "time": "2025-05-11T03:36:00+00:00"
},
{
"name": "ssddanbrown/asserthtml",
'html' => $html,
'parent_id' => null,
'local_id' => 1,
+ 'content_ref' => '',
+ 'archived' => false,
];
}
}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('comments', function (Blueprint $table) {
+ $table->string('content_ref');
+ $table->boolean('archived')->index();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('comments', function (Blueprint $table) {
+ $table->dropColumn('content_ref');
+ $table->dropColumn('archived');
+ });
+ }
+};
'create' => 'Create',
'update' => 'Update',
'edit' => 'Edit',
+ 'archive' => 'Archive',
+ 'unarchive' => 'Un-Archive',
'sort' => 'Sort',
'move' => 'Move',
'copy' => 'Copy',
'comment' => 'Comment',
'comments' => 'Comments',
'comment_add' => 'Add Comment',
+ 'comment_none' => 'No comments to display',
'comment_placeholder' => 'Leave a comment here',
- 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+ 'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+ 'comment_archived_count' => ':count Archived',
+ 'comment_archived_threads' => 'Archived Threads',
'comment_save' => 'Save Comment',
'comment_new' => 'New Comment',
'comment_created' => 'commented :createDiff',
'comment_deleted_success' => 'Comment deleted',
'comment_created_success' => 'Comment added',
'comment_updated_success' => 'Comment updated',
+ 'comment_archive_success' => 'Comment archived',
+ 'comment_unarchive_success' => 'Comment un-archived',
+ 'comment_view' => 'View comment',
+ 'comment_jump_to_thread' => 'Jump to thread',
'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
'comment_in_reply_to' => 'In reply to :commentId',
+ 'comment_reference' => 'Reference',
+ 'comment_reference_outdated' => '(Outdated)',
'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
// Revision
'webhook_delete_notification' => 'Webhook veiksmīgi izdzēsts',
// Imports
- 'import_create' => 'created import',
- 'import_create_notification' => 'Import successfully uploaded',
- 'import_run' => 'updated import',
- 'import_run_notification' => 'Content successfully imported',
- 'import_delete' => 'deleted import',
- 'import_delete_notification' => 'Import successfully deleted',
+ 'import_create' => 'izveidoja importu',
+ 'import_create_notification' => 'Imports veiksmīgi augšupielādēts',
+ 'import_run' => 'atjaunoja importu',
+ 'import_run_notification' => 'Saturs veiksmīgi importēts',
+ 'import_delete' => 'izdzēsa importu',
+ 'import_delete_notification' => 'Imports veiksmīgi dzēsts',
// Users
'user_create' => 'izveidoja lietotāju',
'comment_delete' => 'dzēsa komentāru',
// Sort Rules
- 'sort_rule_create' => 'created sort rule',
- 'sort_rule_create_notification' => 'Sort rule successfully created',
- 'sort_rule_update' => 'updated sort rule',
- 'sort_rule_update_notification' => 'Sort rule successfully updated',
- 'sort_rule_delete' => 'deleted sort rule',
- 'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+ 'sort_rule_create' => 'izveidoja kārtošanas nosacījumu',
+ 'sort_rule_create_notification' => 'Kārtošanas nosacījums veiksmīgi izveidots',
+ 'sort_rule_update' => 'atjaunoja kārtošanas nosacījumu',
+ 'sort_rule_update_notification' => 'Kārtošanas nosacījums veiksmīgi atjaunots',
+ 'sort_rule_delete' => 'izdzēsa kārtošanas nosacījumu',
+ 'sort_rule_delete_notification' => 'Kārtošanas nosacījums veiksmīgi dzēsts',
// Other
'permissions_update' => 'atjaunoja atļaujas',
'terms_of_service' => 'Pakalpojuma noteikumi',
// OpenSearch
- 'opensearch_description' => 'Search :appName',
+ 'opensearch_description' => 'Meklēt :appName',
];
'cancel' => 'Atcelt',
'save' => 'Saglabāt',
'close' => 'Aizvērt',
- 'apply' => 'Apply',
+ 'apply' => 'Pielietot',
'undo' => 'Atsaukt',
'redo' => 'Atcelt atsaukšanu',
'left' => 'Pa kreisi',
'url' => 'URL',
'text_to_display' => 'Attēlojamais teksts',
'title' => 'Nosaukums',
- 'browse_links' => 'Browse links',
+ 'browse_links' => 'Pārlūkot saites',
'open_link' => 'Atvērt saiti',
'open_link_in' => 'Atvērt saiti...',
'open_link_current' => 'Šis logs',
'about' => 'Par redaktoru',
'about_title' => 'Par WYSIWYG redaktoru',
'editor_license' => 'Redaktora licence un autortiesības',
- 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
- 'editor_lexical_license_link' => 'Full license details can be found here.',
+ 'editor_lexical_license' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.',
+ 'editor_lexical_license_link' => 'Pilnu licences informāciju var atrast šeit.',
'editor_tiny_license' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.',
'editor_tiny_license_link' => 'TinyMCE autortiesības un licences detaļas var atrast šeit.',
'save_continue' => 'Saglabāt lapu un turpināt',
'export_pdf' => 'PDF fails',
'export_text' => 'Vienkāršs teksta fails',
'export_md' => 'Markdown fails',
- 'export_zip' => 'Portable ZIP',
+ 'export_zip' => 'Pārceļams ZIP arhīvs',
'default_template' => 'Noklusētā lapas sagatave',
- 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
+ 'default_template_explain' => 'Norādīt lapas sagatavi, kas tiks izmantota kā noklusētais saturs visām jaunājām lapām šajā grāmatā. Ņemiet vērā, ka tā tiks izmantota tikai tad, ja lapas veidotājam ir skatīšanas tiesības izvēlētajai sagatavei.',
'default_template_select' => 'Izvēlēt sagataves lapu',
- 'import' => 'Import',
- 'import_validate' => 'Validate Import',
- 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
- 'import_zip_select' => 'Select ZIP file to upload',
- 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
- 'import_pending' => 'Pending Imports',
- 'import_pending_none' => 'No imports have been started.',
- 'import_continue' => 'Continue Import',
- 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
- 'import_details' => 'Import Details',
- 'import_run' => 'Run Import',
- 'import_size' => ':size Import ZIP Size',
- 'import_uploaded_at' => 'Uploaded :relativeTime',
- 'import_uploaded_by' => 'Uploaded by',
- 'import_location' => 'Import Location',
- 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
- 'import_delete_confirm' => 'Are you sure you want to delete this import?',
- 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
- 'import_errors' => 'Import Errors',
- 'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+ 'import' => 'Importēt',
+ 'import_validate' => 'Pārbaudīt importu',
+ 'import_desc' => 'Importēt grāmatas, nodaļas un lapas izmantojot pārceļamu ZIP arhīvu no šīs vai citas sistēmas instances. Izvēlietites ZIP failu, lai turpinātu. Kad fails ir augšupielādēts un pārbaudīts, jūs varēsiet veikt importa uzstādījumus un to apstiprināt nākamajā skatā.',
+ 'import_zip_select' => 'Izvēlieties ZIP failu, ko augšupielādēt',
+ 'import_zip_validation_errors' => 'Pārbaudot ZIP failu atrastas šādas kļūdas:',
+ 'import_pending' => 'Gaidošie importi',
+ 'import_pending_none' => 'Neviens imports nav uzsākts.',
+ 'import_continue' => 'Turpināt importu',
+ 'import_continue_desc' => 'Pārlūkot saturu, kas tiktu importēts no augšupielādētā ZIP faila. Kad esat gatavs, palaidiet importu, lai pievienotu tā saturu šai sistēmai. Augšupielādētais ZIP fails tiks automātiski izvākts pēc veiksmīga importa.',
+ 'import_details' => 'Importa detaļas',
+ 'import_run' => 'Palaist importu',
+ 'import_size' => ':size importa ZIP izmērs',
+ 'import_uploaded_at' => 'Augšupielādes laiks :relativeTime',
+ 'import_uploaded_by' => 'Augšupielādēja',
+ 'import_location' => 'Importa vieta',
+ 'import_location_desc' => 'Izvēlieties mērķa vietu jūsu importētajam saturam. Jums būs nepieciešamas attiecīgās piekļuves tiesības, lai izveidotu saturu izvēlētajā vietā.',
+ 'import_delete_confirm' => 'Vai tiešām vēlaties dzēst šo importu?',
+ 'import_delete_desc' => 'Šis izdzēsīs augšupielādēto importa ZIP failu, un šo darbību nevarēs atcelt.',
+ 'import_errors' => 'Importa kļūdas',
+ 'import_errors_desc' => 'Importa mēģinājumā atgadījās šīs kļūdas:',
// Permissions and restrictions
'permissions' => 'Atļaujas',
'pages_edit_switch_to_markdown_clean' => '(Iztīrītais saturs)',
'pages_edit_switch_to_markdown_stable' => '(Stabilais saturs)',
'pages_edit_switch_to_wysiwyg' => 'Pārslēgties uz WYSIWYG redaktoru',
- 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
- 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+ 'pages_edit_switch_to_new_wysiwyg' => 'Pārslēgties uz jauno WYSIWYG redaktoru',
+ 'pages_edit_switch_to_new_wysiwyg_desc' => '(Alfa testēšanā)',
'pages_edit_set_changelog' => 'Pievienot izmaiņu aprakstu',
'pages_edit_enter_changelog_desc' => 'Ievadi nelielu aprakstu par vaiktajām izmaiņām',
'pages_edit_enter_changelog' => 'Izmaiņu apraksts',
'watch_desc_comments_page' => 'Paziņot par lapu izmaiņām un jauniem komentāriem.',
'watch_change_default' => 'Izmainīt noklusētos paziņojumu uzstādījumus',
'watch_detail_ignore' => 'Ignorēt paziņojumus',
- 'watch_detail_new' => 'Watching for new pages',
- 'watch_detail_updates' => 'Watching new pages and updates',
- 'watch_detail_comments' => 'Watching new pages, updates & comments',
+ 'watch_detail_new' => 'Vērot jaunas lapas',
+ 'watch_detail_updates' => 'Vērot jaunas lapas un atjauninājumus',
+ 'watch_detail_comments' => 'Vērot jaunas lapas, atjauninājumus un komentārus',
'watch_detail_parent_book' => 'Watching via parent book',
'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
'watch_detail_parent_chapter' => 'Watching via parent chapter',
// Users
'users_cannot_delete_only_admin' => 'Jūs nevarat dzēst vienīgo administratoru',
'users_cannot_delete_guest' => 'Jūs nevarat dzēst lietotāju "viesis"',
- 'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+ 'users_could_not_send_invite' => 'Neizdevās izveidot lietotāju, jo neizdevās nosūtīt ielūguma epastu',
// Roles
'role_cannot_be_edited' => 'Šo lomu nevar rediģēt',
'back_soon' => 'Drīz būs atkal pieejams.',
// Import
- 'import_zip_cant_read' => 'Could not read ZIP file.',
- 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
- 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
- 'import_validation_failed' => 'Import ZIP failed to validate with errors:',
- 'import_zip_failed_notification' => 'Failed to import ZIP file.',
- 'import_perms_books' => 'You are lacking the required permissions to create books.',
- 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
- 'import_perms_pages' => 'You are lacking the required permissions to create pages.',
- 'import_perms_images' => 'You are lacking the required permissions to create images.',
- 'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
+ 'import_zip_cant_read' => 'Nevarēja nolasīt ZIP failu.',
+ 'import_zip_cant_decode_data' => 'Nevarēja atrast un nolasīt data.json saturu ZIP failā.',
+ 'import_zip_no_data' => 'ZIP faila datos nav atrasts grāmatu, nodaļu vai lapu saturs.',
+ 'import_validation_failed' => 'ZIP faila imports ir neveiksmīgs ar šādām kļūdām:',
+ 'import_zip_failed_notification' => 'ZIP faila imports ir neveiksmīgs.',
+ 'import_perms_books' => 'Jums nav nepieciešamo tiesību izveidot grāmatas.',
+ 'import_perms_chapters' => 'Jums nav nepieciešamo tiesību izveidot nodaļas.',
+ 'import_perms_pages' => 'Jums nav nepieciešamo tiesību izveidot lapas.',
+ 'import_perms_images' => 'Jums nav nepieciešamo tiesību izviedot attēlus.',
+ 'import_perms_attachments' => 'Jums nav nepieciešamo tiesību izveidot pielikumus.',
// API errors
'api_no_authorization_found' => 'Pieprasījumā nav atrasts autorizācijas žetons',
'shortcuts' => 'Saīsnes',
'shortcuts_interface' => 'Saskarnes īsceļu iestatījumi',
- 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
- 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.',
+ 'shortcuts_toggle_desc' => 'Šeit jūs varat ieslēgt vai izslēgt sistēmas saskarnes klaviatūras īsceļus, kas tiek izmantoti navigācijai un darbībām.',
+ 'shortcuts_customize_desc' => 'Jūs varat pielāgot katru no zemāk esošajiem īsceļiem. Vienkārši nospiediet nepieciešamo pogu kombināciju pēc tam, kad izvēlēts īsceļa ievadlauks.',
'shortcuts_toggle_label' => 'Klaviatūras saīsnes ieslēgtas',
'shortcuts_section_navigation' => 'Navigācija',
'shortcuts_section_actions' => 'Biežākās darbības',
'shortcuts_save' => 'Saglabāt saīsnes',
- 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
+ 'shortcuts_overlay_desc' => 'Piezīme: kad īsceļi ir ieslēgti, ir pieejams palīdzības lodziņš, kas parādās, nospiežot "?". Tas attēlos pieejamos īsceļus tajā brīdī pieejamajām darbībām.',
'shortcuts_update_success' => 'Saīsņu uzstādījumi ir saglabāt!',
- 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
+ 'shortcuts_overview_desc' => 'Pārvaldīt klaviatūras īsceļus, ko var izmantot sistēmas saskarnes navigācijai.',
'notifications' => 'Paziņojumu iestatījumi',
- 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
+ 'notifications_desc' => 'Pārvaldiet epasta paziņojumus, ko saņemsiet, kad sistēmā tiek veiktas noteiktas darbības.',
'notifications_opt_own_page_changes' => 'Paziņot par izmaiņām manās lapās',
'notifications_opt_own_page_comments' => 'Paziņot par komentāriem manās lapās',
'notifications_opt_comment_replies' => 'Paziņot par atbildēm uz maniem komentāriem',
'notifications_save' => 'Saglabāt iestatījumus',
'notifications_update_success' => 'Paziņojumu iestatījumi ir atjaunoti!',
'notifications_watched' => 'Vērotie un ignorētie vienumi',
- 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
+ 'notifications_watched_desc' => 'Zemāk ir vienumi, kam piemēroti īpaši vērošanas nosacījumi. Lai atjaunintātu savus uzstādījums šiem, apskatiet vienumu un tad sameklējiet vērošanas uzstādījumus sānu kolonnā.',
'auth' => 'Piekļuve un drošība',
'auth_change_password' => 'Mainīt paroli',
'auth_change_password_success' => 'Parole ir nomainīta!',
'profile' => 'Profila informācija',
- 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.',
+ 'profile_desc' => 'Pārvaldiet sava konta detaļas, kas jūs attēlo citiem lietotājiem, kā arī detaļas, kas tiek izmantotas saziņai un sistēmas pielāgošanai.',
'profile_view_public' => 'Skatīt publisko profilu',
- 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.',
+ 'profile_name_desc' => 'Uzstādiet savu vārdu, kas tiks parādīts citiem lietotājiem sistēmā pie jūsu darbībām un jums piederošā satura.',
'profile_email_desc' => 'Šis epasts tiks izmantots paziņojumiem un sistēmas piekļuvei, atkarībā no sistēmā uzstādītās autentifikācijas metodes.',
'profile_email_no_permission' => 'Diemžēl jums nav tiesību mainīt savu epasta adresi. Ja vēlaties to mainīt, jums jāsazinās ar administratoru, lai tas nomaina šo adresi.',
- 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.',
+ 'profile_avatar_desc' => 'Izvēlieties attēlu, kas tiks izmantots, lai jūs attēlotu citiem sistēmas lietotājiem. Ideālā gadījumā šim attēlam jābūt kvadrātaveida, apmēram 256px platumā un augstumā.',
'profile_admin_options' => 'Administratora iestatījumi',
'profile_admin_options_desc' => 'Papildus administratora iestatījumus, kā piemēram, lomu piešķiršanu, var atrast jūsu lietotāja kontā ejot uz "Uzstādījumu > Lietotāji".',
'reg_confirm_restrict_domain_placeholder' => 'Nav ierobežojumu',
// Sorting Settings
- 'sorting' => 'Sorting',
- 'sorting_book_default' => 'Default Book Sort',
- 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.',
- 'sorting_rules' => 'Sort Rules',
- 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.',
- 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books',
- 'sort_rule_create' => 'Create Sort Rule',
- 'sort_rule_edit' => 'Edit Sort Rule',
- 'sort_rule_delete' => 'Delete Sort Rule',
- 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.',
- 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?',
- 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?',
- 'sort_rule_details' => 'Sort Rule Details',
- 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.',
- 'sort_rule_operations' => 'Sort Operations',
- 'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.',
- 'sort_rule_available_operations' => 'Available Operations',
- 'sort_rule_available_operations_empty' => 'No operations remaining',
- 'sort_rule_configured_operations' => 'Configured Operations',
- 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list',
- 'sort_rule_op_asc' => '(Asc)',
- 'sort_rule_op_desc' => '(Desc)',
- 'sort_rule_op_name' => 'Name - Alphabetical',
- 'sort_rule_op_name_numeric' => 'Name - Numeric',
- 'sort_rule_op_created_date' => 'Created Date',
- 'sort_rule_op_updated_date' => 'Updated Date',
- 'sort_rule_op_chapters_first' => 'Chapters First',
- 'sort_rule_op_chapters_last' => 'Chapters Last',
+ 'sorting' => 'Kārtošana',
+ 'sorting_book_default' => 'Noklusētā grāmatu kārtošana',
+ 'sorting_book_default_desc' => 'Izvēlieties noklusēto kārtošanas nosacījumu, ko pielietot jaunām grāmatām. Šis neskars jau esošas grāmatas, un to var izmainīt grāmatas iestatījumos.',
+ 'sorting_rules' => 'Kārtošanas noteikumi',
+ 'sorting_rules_desc' => 'Šīs ir iepriekš noteiktas kārtošanas darbības, ko var pielietot saturam šajā sistēmā.',
+ 'sort_rule_assigned_to_x_books' => 'Pielietots :count grāmatai|Pielietots :count grāmatām',
+ 'sort_rule_create' => 'Izveidot kārtošanas nosacījumu',
+ 'sort_rule_edit' => 'Rediģēt kārtošanas nosacījumu',
+ 'sort_rule_delete' => 'Dzēst kārtošanas nosacījumu',
+ 'sort_rule_delete_desc' => 'Izņemt šo kārtošanas nosacījumu no sistēmas. Grāmatām tiks atjaunota manuāla kārtošana.',
+ 'sort_rule_delete_warn_books' => 'Šis kārtošanas nosacījums pašlaik tiek izmantots :count grāmatām. Vai tiešām vēlaties to dzēst?',
+ 'sort_rule_delete_warn_default' => 'Šis kārtošanas nosacījums pašlaik ir norādīts kā noklusētais. Vai tiešām vēlaties to dzēst?',
+ 'sort_rule_details' => 'Kārtošanas nosacījuma detaļas',
+ 'sort_rule_details_desc' => 'Uzstādiet nosaukumu šim kārtošanas nosacījumam, kas parādīsies sarakstā, kad lietotājs izvēlēsies kārtošanas veidu.',
+ 'sort_rule_operations' => 'Kārtošanas darbības',
+ 'sort_rule_operations_desc' => 'Konfigurējiet kārtošanas darbības pārvelkot tās no pieejamo darb\'biu saraksta. Lietošanas procesā darbības tiks piemērotas saraksta kārtībā no augšas uz apakšu. Jekbādas izmaiņas šeit tiks piemērotas grāmatās, kur šis piemērots, pie saglabāšanas.',
+ 'sort_rule_available_operations' => 'Pieejamās darbības',
+ 'sort_rule_available_operations_empty' => 'Nav atlikušas darbības',
+ 'sort_rule_configured_operations' => 'Uzstādītās darbības',
+ 'sort_rule_configured_operations_empty' => 'Ievelciet/pievienojiet darbības no "Pieejamo darbību" saraksta',
+ 'sort_rule_op_asc' => '(Pieaug.)',
+ 'sort_rule_op_desc' => '(Dilst.)',
+ 'sort_rule_op_name' => 'Nosaukums - alfabētiski',
+ 'sort_rule_op_name_numeric' => 'Nosaukums - numuriski',
+ 'sort_rule_op_created_date' => 'Izveidošanas datums',
+ 'sort_rule_op_updated_date' => 'Atjaunināšanas datums',
+ 'sort_rule_op_chapters_first' => 'Nodaļas pirmās',
+ 'sort_rule_op_chapters_last' => 'Nodaļas pēdējās',
// Maintenance settings
'maint' => 'Apkope',
'role_access_api' => 'Piekļūt sistēmas API',
'role_manage_settings' => 'Pārvaldīt iestatījumus',
'role_export_content' => 'Eksportēt saturu',
- 'role_import_content' => 'Import content',
+ 'role_import_content' => 'Importēt saturu',
'role_editor_change' => 'Mainīt lapu redaktoru',
'role_notifications' => 'Saņemt un pārvaldīt paziņojumus',
'role_asset' => 'Resursa piekļuves tiesības',
'url' => ':attribute formāts nav derīgs.',
'uploaded' => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.',
- 'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
- 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
- 'zip_model_expected' => 'Data object expected but ":type" found.',
- 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
+ 'zip_file' => ':attribute ir jāatsaucas uz failu ZIP arhīvā.',
+ 'zip_file_mime' => ':attribute ir jāatsaucas uz failu ar tipu :validTypes, bet atrasts :foundType.',
+ 'zip_model_expected' => 'Sagaidīts datu objekts, bet atrasts ":type".',
+ 'zip_unique' => ':attribute jābūt unikālam šim objekta tipam ZIP arhīvā.',
// Custom validation lines
'custom' => [
[](https://github.com/BookStackApp/BookStack/actions)
[](https://github.com/BookStackApp/BookStack/actions)
[](https://codeclimate.com/github/BookStackApp/BookStack/maintainability)
-
+<br>
[](https://source.bookstackapp.com/)
[](https://gh-stats.bookstackapp.com/)
-[](https://discord.gg/ztkBqR2)
-[](https://fosstodon.org/@bookstack)
-
+[](https://www.bookstackapp.com/links/discord)
+[](https://www.bookstackapp.com/links/mastodon)
+<br>
[](https://foss.video/c/bookstack)
[](https://www.youtube.com/bookstackapp)
* [Screenshots](https://www.bookstackapp.com/#screenshots)
* [BookStack Blog](https://www.bookstackapp.com/blog)
* [Issue List](https://github.com/BookStackApp/BookStack/issues)
-* [Discord Chat](https://discord.gg/ztkBqR2)
+* [Discord Chat](https://www.bookstackapp.com/links/discord)
* [Support Options](https://www.bookstackapp.com/support/)
## 📚 Project Definition
<img width="240" src="https://www.bookstackapp.com/images/sponsors/route4me.png" alt="Route4Me - Route Optimizer and Route Planner Software">
</a></td>
</tr>
+<tr>
+<td colspan="2" align="center"><a href="https://phamos.eu" target="_blank">
+ <img width="136" src="https://www.bookstackapp.com/images/sponsors/phamos.png" alt="phamos">
+</a></td>
+</tr>
</tbody></table>
## 🛠️ Development & Testing
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-640v440h560v-440H200Zm0 520q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Zm264 300Z"/></svg>
\ No newline at end of file
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z"/></svg>
\ No newline at end of file
import {Component} from './component';
+export interface EditorToolboxChangeEventData {
+ tab: string;
+ open: boolean;
+}
+
export class EditorToolbox extends Component {
+ protected container!: HTMLElement;
+ protected buttons!: HTMLButtonElement[];
+ protected contentElements!: HTMLElement[];
+ protected toggleButton!: HTMLElement;
+ protected editorWrapEl!: HTMLElement;
+
+ protected open: boolean = false;
+ protected tab: string = '';
+
setup() {
// Elements
this.container = this.$el;
- this.buttons = this.$manyRefs.tabButton;
+ this.buttons = this.$manyRefs.tabButton as HTMLButtonElement[];
this.contentElements = this.$manyRefs.tabContent;
this.toggleButton = this.$refs.toggle;
- this.editorWrapEl = this.container.closest('.page-editor');
+ this.editorWrapEl = this.container.closest('.page-editor') as HTMLElement;
this.setupListeners();
// Set the first tab as active on load
- this.setActiveTab(this.contentElements[0].dataset.tabContent);
+ this.setActiveTab(this.contentElements[0].dataset.tabContent || '');
}
- setupListeners() {
+ protected setupListeners(): void {
// Toolbox toggle button click
this.toggleButton.addEventListener('click', () => this.toggle());
// Tab button click
- this.container.addEventListener('click', event => {
- const button = event.target.closest('button');
- if (this.buttons.includes(button)) {
- const name = button.dataset.tab;
+ this.container.addEventListener('click', (event: MouseEvent) => {
+ const button = (event.target as HTMLElement).closest('button');
+ if (button instanceof HTMLButtonElement && this.buttons.includes(button)) {
+ const name = button.dataset.tab || '';
this.setActiveTab(name, true);
}
});
}
- toggle() {
+ protected toggle(): void {
this.container.classList.toggle('open');
const isOpen = this.container.classList.contains('open');
this.toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
this.editorWrapEl.classList.toggle('toolbox-open', isOpen);
+ this.open = isOpen;
+ this.emitState();
}
- setActiveTab(tabName, openToolbox = false) {
+ protected setActiveTab(tabName: string, openToolbox: boolean = false): void {
// Set button visibility
for (const button of this.buttons) {
button.classList.remove('active');
if (openToolbox && !this.container.classList.contains('open')) {
this.toggle();
}
+
+ this.tab = tabName;
+ this.emitState();
+ }
+
+ protected emitState(): void {
+ const data: EditorToolboxChangeEventData = {tab: this.tab, open: this.open};
+ this.$emit('change', data);
}
}
export {Notification} from './notification';
export {OptionalInput} from './optional-input';
export {PageComment} from './page-comment';
+export {PageCommentReference} from './page-comment-reference';
export {PageComments} from './page-comments';
export {PageDisplay} from './page-display';
export {PageEditor} from './page-editor';
--- /dev/null
+import {Component} from "./component";
+import {findTargetNodeAndOffset, hashElement} from "../services/dom";
+import {el} from "../wysiwyg/utils/dom";
+import commentIcon from "@icons/comment.svg";
+import closeIcon from "@icons/close.svg";
+import {debounce, scrollAndHighlightElement} from "../services/util";
+import {EditorToolboxChangeEventData} from "./editor-toolbox";
+import {TabsChangeEvent} from "./tabs";
+
+/**
+ * Track the close function for the current open marker so it can be closed
+ * when another is opened so we only show one marker comment thread at one time.
+ */
+let openMarkerClose: Function|null = null;
+
+export class PageCommentReference extends Component {
+ protected link!: HTMLLinkElement;
+ protected reference!: string;
+ protected markerWrap: HTMLElement|null = null;
+
+ protected viewCommentText!: string;
+ protected jumpToThreadText!: string;
+ protected closeText!: string;
+
+ setup() {
+ this.link = this.$el as HTMLLinkElement;
+ this.reference = this.$opts.reference;
+ this.viewCommentText = this.$opts.viewCommentText;
+ this.jumpToThreadText = this.$opts.jumpToThreadText;
+ this.closeText = this.$opts.closeText;
+
+ // Show within page display area if seen
+ this.showForDisplay();
+
+ // Handle editor view to show on comments toolbox view
+ window.addEventListener('editor-toolbox-change', ((event: CustomEvent<EditorToolboxChangeEventData>) => {
+ const tabName: string = event.detail.tab;
+ const isOpen = event.detail.open;
+ if (tabName === 'comments' && isOpen && this.link.checkVisibility()) {
+ this.showForEditor();
+ } else {
+ this.hideMarker();
+ }
+ }) as EventListener);
+
+ // Handle visibility changes within editor toolbox archived details dropdown
+ window.addEventListener('toggle', event => {
+ if (event.target instanceof HTMLElement && event.target.contains(this.link)) {
+ window.requestAnimationFrame(() => {
+ if (this.link.checkVisibility()) {
+ this.showForEditor();
+ } else {
+ this.hideMarker();
+ }
+ });
+ }
+ }, {capture: true});
+
+ // Handle comments tab changes to hide/show markers & indicators
+ window.addEventListener('tabs-change', ((event: CustomEvent<TabsChangeEvent>) => {
+ const sectionId = event.detail.showing;
+ if (!sectionId.startsWith('comment-tab-panel')) {
+ return;
+ }
+
+ const panel = document.getElementById(sectionId);
+ if (panel?.contains(this.link)) {
+ this.showForDisplay();
+ } else {
+ this.hideMarker();
+ }
+ }) as EventListener);
+ }
+
+ public showForDisplay() {
+ const pageContentArea = document.querySelector('.page-content');
+ if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
+ this.updateMarker(pageContentArea);
+ }
+ }
+
+ protected showForEditor() {
+ const contentWrap = document.querySelector('.editor-content-wrap');
+ if (contentWrap instanceof HTMLElement) {
+ this.updateMarker(contentWrap);
+ }
+
+ const onChange = () => {
+ this.hideMarker();
+ setTimeout(() => {
+ window.$events.remove('editor-html-change', onChange);
+ }, 1);
+ };
+
+ window.$events.listen('editor-html-change', onChange);
+ }
+
+ protected updateMarker(contentContainer: HTMLElement) {
+ // Reset link and existing marker
+ this.link.classList.remove('outdated', 'missing');
+ if (this.markerWrap) {
+ this.markerWrap.remove();
+ }
+
+ const [refId, refHash, refRange] = this.reference.split(':');
+ const refEl = document.getElementById(refId);
+ if (!refEl) {
+ this.link.classList.add('outdated', 'missing');
+ return;
+ }
+
+ const actualHash = hashElement(refEl);
+ if (actualHash !== refHash) {
+ this.link.classList.add('outdated');
+ }
+
+ const marker = el('button', {
+ type: 'button',
+ class: 'content-comment-marker',
+ title: this.viewCommentText,
+ });
+ marker.innerHTML = <string>commentIcon;
+ marker.addEventListener('click', event => {
+ this.showCommentAtMarker(marker);
+ });
+
+ this.markerWrap = el('div', {
+ class: 'content-comment-highlight',
+ }, [marker]);
+
+ contentContainer.append(this.markerWrap);
+ this.positionMarker(refEl, refRange);
+
+ this.link.href = `#${refEl.id}`;
+ this.link.addEventListener('click', (event: MouseEvent) => {
+ event.preventDefault();
+ scrollAndHighlightElement(refEl);
+ });
+
+ const debouncedReposition = debounce(() => {
+ this.positionMarker(refEl, refRange);
+ }, 50, false).bind(this);
+ window.addEventListener('resize', debouncedReposition);
+ }
+
+ protected positionMarker(targetEl: HTMLElement, range: string) {
+ if (!this.markerWrap) {
+ return;
+ }
+
+ const markerParent = this.markerWrap.parentElement as HTMLElement;
+ const parentBounds = markerParent.getBoundingClientRect();
+ let targetBounds = targetEl.getBoundingClientRect();
+ const [rangeStart, rangeEnd] = range.split('-');
+ if (rangeStart && rangeEnd) {
+ const range = new Range();
+ const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
+ const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
+ if (relStart && relEnd) {
+ range.setStart(relStart.node, relStart.offset);
+ range.setEnd(relEnd.node, relEnd.offset);
+ targetBounds = range.getBoundingClientRect();
+ }
+ }
+
+ const relLeft = targetBounds.left - parentBounds.left;
+ const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
+
+ this.markerWrap.style.left = `${relLeft}px`;
+ this.markerWrap.style.top = `${relTop}px`;
+ this.markerWrap.style.width = `${targetBounds.width}px`;
+ this.markerWrap.style.height = `${targetBounds.height}px`;
+ }
+
+ public hideMarker() {
+ // Hide marker and close existing marker windows
+ if (openMarkerClose) {
+ openMarkerClose();
+ }
+ this.markerWrap?.remove();
+ this.markerWrap = null;
+ }
+
+ protected showCommentAtMarker(marker: HTMLElement): void {
+ // Hide marker and close existing marker windows
+ if (openMarkerClose) {
+ openMarkerClose();
+ }
+ marker.hidden = true;
+
+ // Locate relevant comment
+ const commentBox = this.link.closest('.comment-box') as HTMLElement;
+
+ // Build comment window
+ const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
+ const toRemove = readClone.querySelectorAll('.actions, form');
+ for (const el of toRemove) {
+ el.remove();
+ }
+
+ const close = el('button', {type: 'button', title: this.closeText});
+ close.innerHTML = (closeIcon as string);
+ const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
+
+ const commentWindow = el('div', {
+ class: 'content-comment-window'
+ }, [
+ el('div', {
+ class: 'content-comment-window-actions',
+ }, [jump, close]),
+ el('div', {
+ class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
+ }, [readClone]),
+ ]);
+
+ marker.parentElement?.append(commentWindow);
+
+ // Handle interaction within window
+ const closeAction = () => {
+ commentWindow.remove();
+ marker.hidden = false;
+ window.removeEventListener('click', windowCloseAction);
+ openMarkerClose = null;
+ };
+
+ const windowCloseAction = (event: MouseEvent) => {
+ if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
+ closeAction();
+ }
+ };
+ window.addEventListener('click', windowCloseAction);
+
+ openMarkerClose = closeAction;
+ close.addEventListener('click', closeAction.bind(this));
+ jump.addEventListener('click', () => {
+ closeAction();
+ commentBox.scrollIntoView({behavior: 'smooth'});
+ const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
+ highlightTarget.classList.add('anim-highlight');
+ highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
+ });
+
+ // Position window within bounds
+ const commentWindowBounds = commentWindow.getBoundingClientRect();
+ const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
+ if (contentBounds && commentWindowBounds.right > contentBounds.right) {
+ const diff = commentWindowBounds.right - contentBounds.right;
+ commentWindow.style.left = `-${diff}px`;
+ }
+ }
+}
\ No newline at end of file
+++ /dev/null
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComment extends Component {
-
- setup() {
- // Options
- this.commentId = this.$opts.commentId;
- this.commentLocalId = this.$opts.commentLocalId;
- this.commentParentId = this.$opts.commentParentId;
- this.deletedText = this.$opts.deletedText;
- this.updatedText = this.$opts.updatedText;
-
- // Editor reference and text options
- this.wysiwygEditor = null;
- this.wysiwygLanguage = this.$opts.wysiwygLanguage;
- this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
-
- // Element references
- this.container = this.$el;
- this.contentContainer = this.$refs.contentContainer;
- this.form = this.$refs.form;
- this.formCancel = this.$refs.formCancel;
- this.editButton = this.$refs.editButton;
- this.deleteButton = this.$refs.deleteButton;
- this.replyButton = this.$refs.replyButton;
- this.input = this.$refs.input;
-
- this.setupListeners();
- }
-
- setupListeners() {
- if (this.replyButton) {
- this.replyButton.addEventListener('click', () => this.$emit('reply', {
- id: this.commentLocalId,
- element: this.container,
- }));
- }
-
- if (this.editButton) {
- this.editButton.addEventListener('click', this.startEdit.bind(this));
- this.form.addEventListener('submit', this.update.bind(this));
- this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
- }
-
- if (this.deleteButton) {
- this.deleteButton.addEventListener('click', this.delete.bind(this));
- }
- }
-
- toggleEditMode(show) {
- this.contentContainer.toggleAttribute('hidden', show);
- this.form.toggleAttribute('hidden', !show);
- }
-
- startEdit() {
- this.toggleEditMode(true);
-
- if (this.wysiwygEditor) {
- this.wysiwygEditor.focus();
- return;
- }
-
- const config = buildForInput({
- language: this.wysiwygLanguage,
- containerElement: this.input,
- darkMode: document.documentElement.classList.contains('dark-mode'),
- textDirection: this.wysiwygTextDirection,
- translations: {},
- translationMap: window.editor_translations,
- });
-
- window.tinymce.init(config).then(editors => {
- this.wysiwygEditor = editors[0];
- setTimeout(() => this.wysiwygEditor.focus(), 50);
- });
- }
-
- async update(event) {
- event.preventDefault();
- const loading = this.showLoading();
- this.form.toggleAttribute('hidden', true);
-
- const reqData = {
- html: this.wysiwygEditor.getContent(),
- parent_id: this.parentId || null,
- };
-
- try {
- const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
- const newComment = htmlToDom(resp.data);
- this.container.replaceWith(newComment);
- window.$events.success(this.updatedText);
- } catch (err) {
- console.error(err);
- window.$events.showValidationErrors(err);
- this.form.toggleAttribute('hidden', false);
- loading.remove();
- }
- }
-
- async delete() {
- this.showLoading();
-
- await window.$http.delete(`/comment/${this.commentId}`);
- this.$emit('delete');
- this.container.closest('.comment-branch').remove();
- window.$events.success(this.deletedText);
- }
-
- showLoading() {
- const loading = getLoading();
- loading.classList.add('px-l');
- this.container.append(loading);
- return loading;
- }
-
-}
--- /dev/null
+import {Component} from './component';
+import {getLoading, htmlToDom} from '../services/dom';
+import {buildForInput} from '../wysiwyg-tinymce/config';
+import {PageCommentReference} from "./page-comment-reference";
+import {HttpError} from "../services/http";
+
+export interface PageCommentReplyEventData {
+ id: string; // ID of comment being replied to
+ element: HTMLElement; // Container for comment replied to
+}
+
+export interface PageCommentArchiveEventData {
+ new_thread_dom: HTMLElement;
+}
+
+export class PageComment extends Component {
+
+ protected commentId!: string;
+ protected commentLocalId!: string;
+ protected deletedText!: string;
+ protected updatedText!: string;
+ protected archiveText!: string;
+
+ protected wysiwygEditor: any = null;
+ protected wysiwygLanguage!: string;
+ protected wysiwygTextDirection!: string;
+
+ protected container!: HTMLElement;
+ protected contentContainer!: HTMLElement;
+ protected form!: HTMLFormElement;
+ protected formCancel!: HTMLElement;
+ protected editButton!: HTMLElement;
+ protected deleteButton!: HTMLElement;
+ protected replyButton!: HTMLElement;
+ protected archiveButton!: HTMLElement;
+ protected input!: HTMLInputElement;
+
+ setup() {
+ // Options
+ this.commentId = this.$opts.commentId;
+ this.commentLocalId = this.$opts.commentLocalId;
+ this.deletedText = this.$opts.deletedText;
+ this.deletedText = this.$opts.deletedText;
+ this.archiveText = this.$opts.archiveText;
+
+ // Editor reference and text options
+ this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+ this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+ // Element references
+ this.container = this.$el;
+ this.contentContainer = this.$refs.contentContainer;
+ this.form = this.$refs.form as HTMLFormElement;
+ this.formCancel = this.$refs.formCancel;
+ this.editButton = this.$refs.editButton;
+ this.deleteButton = this.$refs.deleteButton;
+ this.replyButton = this.$refs.replyButton;
+ this.archiveButton = this.$refs.archiveButton;
+ this.input = this.$refs.input as HTMLInputElement;
+
+ this.setupListeners();
+ }
+
+ protected setupListeners(): void {
+ if (this.replyButton) {
+ const data: PageCommentReplyEventData = {
+ id: this.commentLocalId,
+ element: this.container,
+ };
+ this.replyButton.addEventListener('click', () => this.$emit('reply', data));
+ }
+
+ if (this.editButton) {
+ this.editButton.addEventListener('click', this.startEdit.bind(this));
+ this.form.addEventListener('submit', this.update.bind(this));
+ this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
+ }
+
+ if (this.deleteButton) {
+ this.deleteButton.addEventListener('click', this.delete.bind(this));
+ }
+
+ if (this.archiveButton) {
+ this.archiveButton.addEventListener('click', this.archive.bind(this));
+ }
+ }
+
+ protected toggleEditMode(show: boolean) : void {
+ this.contentContainer.toggleAttribute('hidden', show);
+ this.form.toggleAttribute('hidden', !show);
+ }
+
+ protected startEdit() : void {
+ this.toggleEditMode(true);
+
+ if (this.wysiwygEditor) {
+ this.wysiwygEditor.focus();
+ return;
+ }
+
+ const config = buildForInput({
+ language: this.wysiwygLanguage,
+ containerElement: this.input,
+ darkMode: document.documentElement.classList.contains('dark-mode'),
+ textDirection: this.wysiwygTextDirection,
+ drawioUrl: '',
+ pageId: 0,
+ translations: {},
+ translationMap: (window as unknown as Record<string, Object>).editor_translations,
+ });
+
+ (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
+ this.wysiwygEditor = editors[0];
+ setTimeout(() => this.wysiwygEditor.focus(), 50);
+ });
+ }
+
+ protected async update(event: Event): Promise<void> {
+ event.preventDefault();
+ const loading = this.showLoading();
+ this.form.toggleAttribute('hidden', true);
+
+ const reqData = {
+ html: this.wysiwygEditor.getContent(),
+ };
+
+ try {
+ const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
+ const newComment = htmlToDom(resp.data as string);
+ this.container.replaceWith(newComment);
+ window.$events.success(this.updatedText);
+ } catch (err) {
+ console.error(err);
+ if (err instanceof HttpError) {
+ window.$events.showValidationErrors(err);
+ }
+ this.form.toggleAttribute('hidden', false);
+ loading.remove();
+ }
+ }
+
+ protected async delete(): Promise<void> {
+ this.showLoading();
+
+ await window.$http.delete(`/comment/${this.commentId}`);
+ this.$emit('delete');
+
+ const branch = this.container.closest('.comment-branch');
+ if (branch instanceof HTMLElement) {
+ const refs = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference');
+ for (const ref of refs) {
+ ref.hideMarker();
+ }
+ branch.remove();
+ }
+
+ window.$events.success(this.deletedText);
+ }
+
+ protected async archive(): Promise<void> {
+ this.showLoading();
+ const isArchived = this.archiveButton.dataset.isArchived === 'true';
+ const action = isArchived ? 'unarchive' : 'archive';
+
+ const response = await window.$http.put(`/comment/${this.commentId}/${action}`);
+ window.$events.success(this.archiveText);
+ const eventData: PageCommentArchiveEventData = {new_thread_dom: htmlToDom(response.data as string)};
+ this.$emit(action, eventData);
+
+ const branch = this.container.closest('.comment-branch') as HTMLElement;
+ const references = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference');
+ for (const reference of references) {
+ reference.hideMarker();
+ }
+ branch.remove();
+ }
+
+ protected showLoading(): HTMLElement {
+ const loading = getLoading();
+ loading.classList.add('px-l');
+ this.container.append(loading);
+ return loading;
+ }
+}
+++ /dev/null
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComments extends Component {
-
- setup() {
- this.elem = this.$el;
- this.pageId = Number(this.$opts.pageId);
-
- // Element references
- this.container = this.$refs.commentContainer;
- this.commentCountBar = this.$refs.commentCountBar;
- this.commentsTitle = this.$refs.commentsTitle;
- this.addButtonContainer = this.$refs.addButtonContainer;
- this.replyToRow = this.$refs.replyToRow;
- this.formContainer = this.$refs.formContainer;
- this.form = this.$refs.form;
- this.formInput = this.$refs.formInput;
- this.formReplyLink = this.$refs.formReplyLink;
- this.addCommentButton = this.$refs.addCommentButton;
- this.hideFormButton = this.$refs.hideFormButton;
- this.removeReplyToButton = this.$refs.removeReplyToButton;
-
- // WYSIWYG options
- this.wysiwygLanguage = this.$opts.wysiwygLanguage;
- this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
- this.wysiwygEditor = null;
-
- // Translations
- this.createdText = this.$opts.createdText;
- this.countText = this.$opts.countText;
-
- // Internal State
- this.parentId = null;
- this.formReplyText = this.formReplyLink?.textContent || '';
-
- this.setupListeners();
- }
-
- setupListeners() {
- this.elem.addEventListener('page-comment-delete', () => {
- setTimeout(() => this.updateCount(), 1);
- this.hideForm();
- });
-
- this.elem.addEventListener('page-comment-reply', event => {
- this.setReply(event.detail.id, event.detail.element);
- });
-
- if (this.form) {
- this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
- this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
- this.addCommentButton.addEventListener('click', this.showForm.bind(this));
- this.form.addEventListener('submit', this.saveComment.bind(this));
- }
- }
-
- saveComment(event) {
- event.preventDefault();
- event.stopPropagation();
-
- const loading = getLoading();
- loading.classList.add('px-l');
- this.form.after(loading);
- this.form.toggleAttribute('hidden', true);
-
- const reqData = {
- html: this.wysiwygEditor.getContent(),
- parent_id: this.parentId || null,
- };
-
- window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
- const newElem = htmlToDom(resp.data);
-
- if (reqData.parent_id) {
- this.formContainer.after(newElem);
- } else {
- this.container.append(newElem);
- }
-
- window.$events.success(this.createdText);
- this.hideForm();
- this.updateCount();
- }).catch(err => {
- this.form.toggleAttribute('hidden', false);
- window.$events.showValidationErrors(err);
- });
-
- this.form.toggleAttribute('hidden', false);
- loading.remove();
- }
-
- updateCount() {
- const count = this.getCommentCount();
- this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count});
- }
-
- resetForm() {
- this.removeEditor();
- this.formInput.value = '';
- this.parentId = null;
- this.replyToRow.toggleAttribute('hidden', true);
- this.container.append(this.formContainer);
- }
-
- showForm() {
- this.removeEditor();
- this.formContainer.toggleAttribute('hidden', false);
- this.addButtonContainer.toggleAttribute('hidden', true);
- this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
- this.loadEditor();
- }
-
- hideForm() {
- this.resetForm();
- this.formContainer.toggleAttribute('hidden', true);
- if (this.getCommentCount() > 0) {
- this.elem.append(this.addButtonContainer);
- } else {
- this.commentCountBar.append(this.addButtonContainer);
- }
- this.addButtonContainer.toggleAttribute('hidden', false);
- }
-
- loadEditor() {
- if (this.wysiwygEditor) {
- this.wysiwygEditor.focus();
- return;
- }
-
- const config = buildForInput({
- language: this.wysiwygLanguage,
- containerElement: this.formInput,
- darkMode: document.documentElement.classList.contains('dark-mode'),
- textDirection: this.wysiwygTextDirection,
- translations: {},
- translationMap: window.editor_translations,
- });
-
- window.tinymce.init(config).then(editors => {
- this.wysiwygEditor = editors[0];
- setTimeout(() => this.wysiwygEditor.focus(), 50);
- });
- }
-
- removeEditor() {
- if (this.wysiwygEditor) {
- this.wysiwygEditor.remove();
- this.wysiwygEditor = null;
- }
- }
-
- getCommentCount() {
- return this.container.querySelectorAll('[component="page-comment"]').length;
- }
-
- setReply(commentLocalId, commentElement) {
- const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children');
- targetFormLocation.append(this.formContainer);
- this.showForm();
- this.parentId = commentLocalId;
- this.replyToRow.toggleAttribute('hidden', false);
- this.formReplyLink.textContent = this.formReplyText.replace('1234', this.parentId);
- this.formReplyLink.href = `#comment${this.parentId}`;
- }
-
- removeReplyTo() {
- this.parentId = null;
- this.replyToRow.toggleAttribute('hidden', true);
- this.container.append(this.formContainer);
- this.showForm();
- }
-
-}
--- /dev/null
+import {Component} from './component';
+import {getLoading, htmlToDom} from '../services/dom';
+import {buildForInput} from '../wysiwyg-tinymce/config';
+import {Tabs} from "./tabs";
+import {PageCommentReference} from "./page-comment-reference";
+import {scrollAndHighlightElement} from "../services/util";
+import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
+
+export class PageComments extends Component {
+
+ private elem!: HTMLElement;
+ private pageId!: number;
+ private container!: HTMLElement;
+ private commentCountBar!: HTMLElement;
+ private activeTab!: HTMLElement;
+ private archivedTab!: HTMLElement;
+ private addButtonContainer!: HTMLElement;
+ private archiveContainer!: HTMLElement;
+ private replyToRow!: HTMLElement;
+ private referenceRow!: HTMLElement;
+ private formContainer!: HTMLElement;
+ private form!: HTMLFormElement;
+ private formInput!: HTMLInputElement;
+ private formReplyLink!: HTMLAnchorElement;
+ private formReferenceLink!: HTMLAnchorElement;
+ private addCommentButton!: HTMLElement;
+ private hideFormButton!: HTMLElement;
+ private removeReplyToButton!: HTMLElement;
+ private removeReferenceButton!: HTMLElement;
+ private wysiwygLanguage!: string;
+ private wysiwygTextDirection!: string;
+ private wysiwygEditor: any = null;
+ private createdText!: string;
+ private countText!: string;
+ private archivedCountText!: string;
+ private parentId: number | null = null;
+ private contentReference: string = '';
+ private formReplyText: string = '';
+
+ setup() {
+ this.elem = this.$el;
+ this.pageId = Number(this.$opts.pageId);
+
+ // Element references
+ this.container = this.$refs.commentContainer;
+ this.commentCountBar = this.$refs.commentCountBar;
+ this.activeTab = this.$refs.activeTab;
+ this.archivedTab = this.$refs.archivedTab;
+ this.addButtonContainer = this.$refs.addButtonContainer;
+ this.archiveContainer = this.$refs.archiveContainer;
+ this.replyToRow = this.$refs.replyToRow;
+ this.referenceRow = this.$refs.referenceRow;
+ this.formContainer = this.$refs.formContainer;
+ this.form = this.$refs.form as HTMLFormElement;
+ this.formInput = this.$refs.formInput as HTMLInputElement;
+ this.formReplyLink = this.$refs.formReplyLink as HTMLAnchorElement;
+ this.formReferenceLink = this.$refs.formReferenceLink as HTMLAnchorElement;
+ this.addCommentButton = this.$refs.addCommentButton;
+ this.hideFormButton = this.$refs.hideFormButton;
+ this.removeReplyToButton = this.$refs.removeReplyToButton;
+ this.removeReferenceButton = this.$refs.removeReferenceButton;
+
+ // WYSIWYG options
+ this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+ this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+ // Translations
+ this.createdText = this.$opts.createdText;
+ this.countText = this.$opts.countText;
+ this.archivedCountText = this.$opts.archivedCountText;
+
+ this.formReplyText = this.formReplyLink?.textContent || '';
+
+ this.setupListeners();
+ }
+
+ protected setupListeners(): void {
+ this.elem.addEventListener('page-comment-delete', () => {
+ setTimeout(() => this.updateCount(), 1);
+ this.hideForm();
+ });
+
+ this.elem.addEventListener('page-comment-reply', ((event: CustomEvent<PageCommentReplyEventData>) => {
+ this.setReply(event.detail.id, event.detail.element);
+ }) as EventListener);
+
+ this.elem.addEventListener('page-comment-archive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+ this.archiveContainer.append(event.detail.new_thread_dom);
+ setTimeout(() => this.updateCount(), 1);
+ }) as EventListener);
+
+ this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+ this.container.append(event.detail.new_thread_dom);
+ setTimeout(() => this.updateCount(), 1);
+ }) as EventListener);
+
+ if (this.form) {
+ this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
+ this.removeReferenceButton.addEventListener('click', () => this.setContentReference(''));
+ this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
+ this.addCommentButton.addEventListener('click', this.showForm.bind(this));
+ this.form.addEventListener('submit', this.saveComment.bind(this));
+ }
+ }
+
+ protected saveComment(event: SubmitEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const loading = getLoading();
+ loading.classList.add('px-l');
+ this.form.after(loading);
+ this.form.toggleAttribute('hidden', true);
+
+ const reqData = {
+ html: this.wysiwygEditor.getContent(),
+ parent_id: this.parentId || null,
+ content_ref: this.contentReference,
+ };
+
+ window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
+ const newElem = htmlToDom(resp.data as string);
+
+ if (reqData.parent_id) {
+ this.formContainer.after(newElem);
+ } else {
+ this.container.append(newElem);
+ }
+
+ const refs = window.$components.allWithinElement<PageCommentReference>(newElem, 'page-comment-reference');
+ for (const ref of refs) {
+ ref.showForDisplay();
+ }
+
+ window.$events.success(this.createdText);
+ this.hideForm();
+ this.updateCount();
+ }).catch(err => {
+ this.form.toggleAttribute('hidden', false);
+ window.$events.showValidationErrors(err);
+ });
+
+ this.form.toggleAttribute('hidden', false);
+ loading.remove();
+ }
+
+ protected updateCount(): void {
+ const activeCount = this.getActiveThreadCount();
+ this.activeTab.textContent = window.$trans.choice(this.countText, activeCount);
+ const archivedCount = this.getArchivedThreadCount();
+ this.archivedTab.textContent = window.$trans.choice(this.archivedCountText, archivedCount);
+ }
+
+ protected resetForm(): void {
+ this.removeEditor();
+ this.formInput.value = '';
+ this.setContentReference('');
+ this.removeReplyTo();
+ }
+
+ protected showForm(): void {
+ this.removeEditor();
+ this.formContainer.toggleAttribute('hidden', false);
+ this.addButtonContainer.toggleAttribute('hidden', true);
+ this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+ this.loadEditor();
+
+ // Ensure the active comments tab is displaying
+ const tabs = window.$components.firstOnElement(this.elem, 'tabs');
+ if (tabs instanceof Tabs) {
+ tabs.show('comment-tab-panel-active');
+ }
+ }
+
+ protected hideForm(): void {
+ this.resetForm();
+ this.formContainer.toggleAttribute('hidden', true);
+ if (this.getActiveThreadCount() > 0) {
+ this.elem.append(this.addButtonContainer);
+ } else {
+ this.commentCountBar.append(this.addButtonContainer);
+ }
+ this.addButtonContainer.toggleAttribute('hidden', false);
+ }
+
+ protected loadEditor(): void {
+ if (this.wysiwygEditor) {
+ this.wysiwygEditor.focus();
+ return;
+ }
+
+ const config = buildForInput({
+ language: this.wysiwygLanguage,
+ containerElement: this.formInput,
+ darkMode: document.documentElement.classList.contains('dark-mode'),
+ textDirection: this.wysiwygTextDirection,
+ drawioUrl: '',
+ pageId: 0,
+ translations: {},
+ translationMap: (window as unknown as Record<string, Object>).editor_translations,
+ });
+
+ (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
+ this.wysiwygEditor = editors[0];
+ setTimeout(() => this.wysiwygEditor.focus(), 50);
+ });
+ }
+
+ protected removeEditor(): void {
+ if (this.wysiwygEditor) {
+ this.wysiwygEditor.remove();
+ this.wysiwygEditor = null;
+ }
+ }
+
+ protected getActiveThreadCount(): number {
+ return this.container.querySelectorAll(':scope > .comment-branch:not([hidden])').length;
+ }
+
+ protected getArchivedThreadCount(): number {
+ return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length;
+ }
+
+ protected setReply(commentLocalId: string, commentElement: HTMLElement): void {
+ const targetFormLocation = (commentElement.closest('.comment-branch') as HTMLElement).querySelector('.comment-branch-children') as HTMLElement;
+ targetFormLocation.append(this.formContainer);
+ this.showForm();
+ this.parentId = Number(commentLocalId);
+ this.replyToRow.toggleAttribute('hidden', false);
+ this.formReplyLink.textContent = this.formReplyText.replace('1234', String(this.parentId));
+ this.formReplyLink.href = `#comment${this.parentId}`;
+ }
+
+ protected removeReplyTo(): void {
+ this.parentId = null;
+ this.replyToRow.toggleAttribute('hidden', true);
+ this.container.append(this.formContainer);
+ this.showForm();
+ }
+
+ public startNewComment(contentReference: string): void {
+ this.removeReplyTo();
+ this.setContentReference(contentReference);
+ }
+
+ protected setContentReference(reference: string): void {
+ this.contentReference = reference;
+ this.referenceRow.toggleAttribute('hidden', !Boolean(reference));
+ const [id] = reference.split(':');
+ this.formReferenceLink.href = `#${id}`;
+ this.formReferenceLink.onclick = function(event) {
+ event.preventDefault();
+ const el = document.getElementById(id);
+ if (el) {
+ scrollAndHighlightElement(el);
+ }
+ };
+ }
+
+}
-import * as DOM from '../services/dom.ts';
+import * as DOM from '../services/dom';
import {Component} from './component';
-import {copyTextToClipboard} from '../services/clipboard.ts';
+import {copyTextToClipboard} from '../services/clipboard';
+import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom";
+import {PageComments} from "./page-comments";
export class Pointer extends Component {
+ protected showing: boolean = false;
+ protected isMakingSelection: boolean = false;
+ protected targetElement: HTMLElement|null = null;
+ protected targetSelectionRange: Range|null = null;
+
+ protected pointer!: HTMLElement;
+ protected linkInput!: HTMLInputElement;
+ protected linkButton!: HTMLElement;
+ protected includeInput!: HTMLInputElement;
+ protected includeButton!: HTMLElement;
+ protected sectionModeButton!: HTMLElement;
+ protected commentButton!: HTMLElement;
+ protected modeToggles!: HTMLElement[];
+ protected modeSections!: HTMLElement[];
+ protected pageId!: string;
+
setup() {
- this.container = this.$el;
this.pointer = this.$refs.pointer;
- this.linkInput = this.$refs.linkInput;
+ this.linkInput = this.$refs.linkInput as HTMLInputElement;
this.linkButton = this.$refs.linkButton;
- this.includeInput = this.$refs.includeInput;
+ this.includeInput = this.$refs.includeInput as HTMLInputElement;
this.includeButton = this.$refs.includeButton;
this.sectionModeButton = this.$refs.sectionModeButton;
+ this.commentButton = this.$refs.commentButton;
this.modeToggles = this.$manyRefs.modeToggle;
this.modeSections = this.$manyRefs.modeSection;
this.pageId = this.$opts.pageId;
- // Instance variables
- this.showing = false;
- this.isSelection = false;
-
this.setupListeners();
}
// Select all contents on input click
DOM.onSelect([this.includeInput, this.linkInput], event => {
- event.target.select();
+ (event.target as HTMLInputElement).select();
event.stopPropagation();
});
// Hide pointer when clicking away
DOM.onEvents(document.body, ['click', 'focus'], () => {
- if (!this.showing || this.isSelection) return;
+ if (!this.showing || this.isMakingSelection) return;
this.hidePointer();
});
const pageContent = document.querySelector('.page-content');
DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
event.stopPropagation();
- const targetEl = event.target.closest('[id^="bkmrk"]');
- if (targetEl && window.getSelection().toString().length > 0) {
- this.showPointerAtTarget(targetEl, event.pageX, false);
+ const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]');
+ if (targetEl instanceof HTMLElement && (window.getSelection() || '').toString().length > 0) {
+ const xPos = (event instanceof MouseEvent) ? event.pageX : 0;
+ this.showPointerAtTarget(targetEl, xPos, false);
}
});
// Toggle between pointer modes
DOM.onSelect(this.modeToggles, event => {
+ const targetToggle = (event.target as HTMLElement);
for (const section of this.modeSections) {
- const show = !section.contains(event.target);
+ const show = !section.contains(targetToggle);
section.toggleAttribute('hidden', !show);
}
- this.modeToggles.find(b => b !== event.target).focus();
+ const otherToggle = this.modeToggles.find(b => b !== targetToggle);
+ otherToggle && otherToggle.focus();
});
+
+ if (this.commentButton) {
+ DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
+ }
}
hidePointer() {
- this.pointer.style.display = null;
+ this.pointer.style.removeProperty('display');
this.showing = false;
+ this.targetElement = null;
+ this.targetSelectionRange = null;
}
/**
* Move and display the pointer at the given element, targeting the given screen x-position if possible.
- * @param {Element} element
- * @param {Number} xPosition
- * @param {Boolean} keyboardMode
*/
- showPointerAtTarget(element, xPosition, keyboardMode) {
- this.updateForTarget(element);
+ showPointerAtTarget(element: HTMLElement, xPosition: number, keyboardMode: boolean) {
+ this.targetElement = element;
+ this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
+ this.updateDomForTarget(element);
this.pointer.style.display = 'block';
const targetBounds = element.getBoundingClientRect();
this.pointer.style.top = `${yOffset}px`;
this.showing = true;
- this.isSelection = true;
+ this.isMakingSelection = true;
setTimeout(() => {
- this.isSelection = false;
+ this.isMakingSelection = false;
}, 100);
const scrollListener = () => {
this.hidePointer();
- window.removeEventListener('scroll', scrollListener, {passive: true});
+ window.removeEventListener('scroll', scrollListener);
};
- element.parentElement.insertBefore(this.pointer, element);
+ element.parentElement?.insertBefore(this.pointer, element);
if (!keyboardMode) {
window.addEventListener('scroll', scrollListener, {passive: true});
}
/**
* Update the pointer inputs/content for the given target element.
- * @param {?Element} element
*/
- updateForTarget(element) {
+ updateDomForTarget(element: HTMLElement) {
const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
const includeTag = `{{@${this.pageId}#${element.id}}}`;
// Update anchor if present
const editAnchor = this.pointer.querySelector('#pointer-edit');
- if (editAnchor && element) {
+ if (editAnchor instanceof HTMLAnchorElement && element) {
const {editHref} = editAnchor.dataset;
const elementId = element.id;
// Get the first 50 characters.
- const queryContent = element.textContent && element.textContent.substring(0, 50);
+ const queryContent = (element.textContent || '').substring(0, 50);
editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
}
}
enterSectionSelectMode() {
- const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
+ const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]')) as HTMLElement[];
for (const section of sections) {
section.setAttribute('tabindex', '0');
}
sections[0].focus();
DOM.onEnterPress(sections, event => {
- this.showPointerAtTarget(event.target, 0, true);
+ this.showPointerAtTarget(event.target as HTMLElement, 0, true);
this.pointer.focus();
});
}
+ createCommentAtPointer() {
+ if (!this.targetElement) {
+ return;
+ }
+
+ const refId = this.targetElement.id;
+ const hash = hashElement(this.targetElement);
+ let range = '';
+ if (this.targetSelectionRange) {
+ const commonContainer = this.targetSelectionRange.commonAncestorContainer;
+ if (this.targetElement.contains(commonContainer)) {
+ const start = normalizeNodeTextOffsetToParent(
+ this.targetSelectionRange.startContainer,
+ this.targetSelectionRange.startOffset,
+ this.targetElement
+ );
+ const end = normalizeNodeTextOffsetToParent(
+ this.targetSelectionRange.endContainer,
+ this.targetSelectionRange.endOffset,
+ this.targetElement
+ );
+ range = `${start}-${end}`;
+ }
+ }
+
+ const reference = `${refId}:${hash}:${range}`;
+ const pageComments = window.$components.first('page-comments') as PageComments;
+ pageComments.startNewComment(reference);
+ }
+
}
import {Component} from './component';
+export interface TabsChangeEvent {
+ showing: string;
+}
+
/**
* Tabs
* Uses accessible attributes to drive its functionality.
*/
export class Tabs extends Component {
+ protected container!: HTMLElement;
+ protected tabList!: HTMLElement;
+ protected tabs!: HTMLElement[];
+ protected panels!: HTMLElement[];
+
+ protected activeUnder!: number;
+ protected active: null|boolean = null;
+
setup() {
this.container = this.$el;
- this.tabList = this.container.querySelector('[role="tablist"]');
+ this.tabList = this.container.querySelector('[role="tablist"]') as HTMLElement;
this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]'));
this.panels = Array.from(this.container.querySelectorAll(':scope > [role="tabpanel"], :scope > * > [role="tabpanel"]'));
this.activeUnder = this.$opts.activeUnder ? Number(this.$opts.activeUnder) : 10000;
- this.active = null;
this.container.addEventListener('click', event => {
- const tab = event.target.closest('[role="tab"]');
- if (tab && this.tabs.includes(tab)) {
- this.show(tab.getAttribute('aria-controls'));
+ const tab = (event.target as HTMLElement).closest('[role="tab"]');
+ if (tab instanceof HTMLElement && this.tabs.includes(tab)) {
+ this.show(tab.getAttribute('aria-controls') || '');
}
});
this.updateActiveState();
}
- show(sectionId) {
+ public show(sectionId: string): void {
for (const panel of this.panels) {
panel.toggleAttribute('hidden', panel.id !== sectionId);
}
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
}
- this.$emit('change', {showing: sectionId});
+ const data: TabsChangeEvent = {showing: sectionId};
+ this.$emit('change', data);
}
- updateActiveState() {
+ protected updateActiveState(): void {
const active = window.innerWidth < this.activeUnder;
if (active === this.active) {
return;
this.active = active;
}
- activate() {
+ protected activate(): void {
const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0];
this.show(panelToShow.id);
this.tabList.toggleAttribute('hidden', false);
}
- deactivate() {
+ protected deactivate(): void {
for (const panel of this.panels) {
panel.removeAttribute('hidden');
}
expect(caseB).toEqual('an orange angry big dinosaur');
});
+ test('it provides count as a replacement by default', () => {
+ const caseA = $trans.choice(`:count cats|:count dogs`, 4);
+ expect(caseA).toEqual('4 dogs');
+ });
+
test('not provided replacements are left as-is', () => {
const caseA = $trans.choice(`An :a dog`, 5, {});
expect(caseA).toEqual('An :a dog');
/**
* Get all the components of the given name.
*/
- public get(name: string): Component[] {
- return this.components[name] || [];
+ public get<T extends Component>(name: string): T[] {
+ return (this.components[name] || []) as T[];
}
/**
const elComponents = this.elementComponentMap.get(element) || {};
return elComponents[name] || null;
}
+
+ public allWithinElement<T extends Component>(element: HTMLElement, name: string): T[] {
+ const components = this.get<T>(name);
+ return components.filter(c => element.contains(c.$el));
+ }
}
+import {cyrb53} from "./util";
+
/**
* Check if the given param is a HTMLElement
*/
/**
* Helper to listen to multiple DOM events
*/
-export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void {
- for (const eventName of events) {
- listenerElement.addEventListener(eventName, callback);
+export function onEvents(listenerElement: Element|null, events: string[], callback: (e: Event) => any): void {
+ if (listenerElement) {
+ for (const eventName of events) {
+ listenerElement.addEventListener(eventName, callback);
+ }
}
}
return firstChild;
}
+
+/**
+ * For the given node and offset, return an adjusted offset that's relative to the given parent element.
+ */
+export function normalizeNodeTextOffsetToParent(node: Node, offset: number, parentElement: HTMLElement): number {
+ if (!parentElement.contains(node)) {
+ throw new Error('ParentElement must be a prent of element');
+ }
+
+ let normalizedOffset = offset;
+ let currentNode: Node|null = node.nodeType === Node.TEXT_NODE ?
+ node : node.childNodes[offset];
+
+ while (currentNode !== parentElement && currentNode) {
+ if (currentNode.previousSibling) {
+ currentNode = currentNode.previousSibling;
+ normalizedOffset += (currentNode.textContent?.length || 0);
+ } else {
+ currentNode = currentNode.parentNode;
+ }
+ }
+
+ return normalizedOffset;
+}
+
+/**
+ * Find the target child node and adjusted offset based on a parent node and text offset.
+ * Returns null if offset not found within the given parent node.
+ */
+export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number): ({node: Node, offset: number}|null) {
+ if (offset === 0) {
+ return { node: parentNode, offset: 0 };
+ }
+
+ let currentOffset = 0;
+ let currentNode = null;
+
+ for (let i = 0; i < parentNode.childNodes.length; i++) {
+ currentNode = parentNode.childNodes[i];
+
+ if (currentNode.nodeType === Node.TEXT_NODE) {
+ // For text nodes, count the length of their content
+ // Returns if within range
+ const textLength = (currentNode.textContent || '').length;
+ if (currentOffset + textLength >= offset) {
+ return {
+ node: currentNode,
+ offset: offset - currentOffset
+ };
+ }
+
+ currentOffset += textLength;
+ } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
+ // Otherwise, if an element, track the text length and search within
+ // if in range for the target offset
+ const elementTextLength = (currentNode.textContent || '').length;
+ if (currentOffset + elementTextLength >= offset) {
+ return findTargetNodeAndOffset(currentNode as HTMLElement, offset - currentOffset);
+ }
+
+ currentOffset += elementTextLength;
+ }
+ }
+
+ // Return null if not found within range
+ return null;
+}
+
+/**
+ * Create a hash for the given HTML element content.
+ */
+export function hashElement(element: HTMLElement): string {
+ const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
+ return cyrb53(normalisedElemText);
+}
\ No newline at end of file
import {HttpError} from "./http";
+type Listener = (data: any) => void;
+
export class EventManager {
- protected listeners: Record<string, ((data: any) => void)[]> = {};
+ protected listeners: Record<string, Listener[]> = {};
protected stack: {name: string, data: {}}[] = [];
/**
this.listeners[eventName].push(callback);
}
+ /**
+ * Remove an event listener which is using the given callback for the given event name.
+ */
+ remove(eventName: string, callback: Listener): void {
+ const listeners = this.listeners[eventName] || [];
+ const index = listeners.indexOf(callback);
+ if (index !== -1) {
+ listeners.splice(index, 1);
+ }
+ }
+
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
/**
* Notify of standard server-provided validation errors.
*/
- showValidationErrors(responseErr: {status?: number, data?: object}): void {
- if (!responseErr.status) return;
+ showValidationErrors(responseErr: HttpError): void {
if (responseErr.status === 422 && responseErr.data) {
const message = Object.values(responseErr.data).flat().join('\n');
this.error(message);
* to use. Similar format at Laravel's 'trans_choice' helper.
*/
choice(translation: string, count: number, replacements: Record<string, string> = {}): string {
+ replacements = Object.assign({}, {count: String(count)}, replacements);
const splitText = translation.split('|');
const exactCountRegex = /^{([0-9]+)}/;
const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
export function importVersioned(moduleName: string): Promise<object> {
const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`);
return import(importPath);
+}
+
+/*
+ cyrb53 (c) 2018 bryc (github.com/bryc)
+ License: Public domain (or MIT if needed). Attribution appreciated.
+ A fast and simple 53-bit string hash function with decent collision resistance.
+ Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
+ Taken from: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
+*/
+export function cyrb53(str: string, seed: number = 0): string {
+ let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
+ for(let i = 0, ch; i < str.length; i++) {
+ ch = str.charCodeAt(i);
+ h1 = Math.imul(h1 ^ ch, 2654435761);
+ h2 = Math.imul(h2 ^ ch, 1597334677);
+ }
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+ return String((4294967296 * (2097151 & h2) + (h1 >>> 0)));
}
\ No newline at end of file
animation-duration: 180ms;
animation-delay: 0s;
animation-timing-function: cubic-bezier(.62, .28, .23, .99);
+}
+
+@keyframes highlight {
+ 0% {
+ background-color: var(--color-primary-light);
+ }
+ 33% {
+ background-color: transparent;
+ }
+ 66% {
+ background-color: var(--color-primary-light);
+ }
+ 100% {
+ background-color: transparent;
+ }
+}
+
+.anim-highlight {
+ animation-name: highlight;
+ animation-duration: 2s;
+ animation-delay: 0s;
+ animation-timing-function: linear;
}
\ No newline at end of file
border-bottom: 0;
padding: 0 vars.$xs;
}
+.tab-container [role="tabpanel"].no-outline:focus {
+ outline: none;
+}
.image-picker .none {
display: none;
height: calc(100% - vars.$m);
}
+.comment-reference-indicator-wrap a {
+ float: left;
+ margin-top: vars.$xs;
+ font-size: 12px;
+ display: inline-block;
+ font-weight: bold;
+ position: relative;
+ border-radius: 4px;
+ overflow: hidden;
+ padding: 2px 6px 2px 0;
+ margin-inline-end: vars.$xs;
+ color: var(--color-link);
+ span {
+ display: none;
+ }
+ &.outdated span {
+ display: inline;
+ }
+ &.outdated.missing {
+ color: var(--color-warning);
+ pointer-events: none;
+ }
+ svg {
+ width: 24px;
+ margin-inline-end: 0;
+ }
+ &:after {
+ background-color: currentColor;
+ content: '';
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ left: 0;
+ top: 0;
+ opacity: 0.15;
+ }
+ &[href="#"] {
+ color: #444;
+ pointer-events: none;
+ }
+}
+
+.comment-branch .comment-box {
+ margin-bottom: vars.$m;
+}
+
.comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator {
display: none;
}
display: block;
}
+.comment-container .empty-state {
+ display: none;
+}
+.comment-container:not(:has([component="page-comment"])) .empty-state {
+ display: block;
+}
+
.comment-container-compact .comment-box {
+ margin-bottom: vars.$xs;
.meta {
font-size: 0.8rem;
}
width: vars.$m;
}
+.comment-container-super-compact .comment-box {
+ .meta {
+ font-size: 12px;
+ }
+ .avatar {
+ width: 22px;
+ height: 22px;
+ margin-inline-end: 2px !important;
+ }
+ .content {
+ padding: vars.$xxs vars.$s;
+ line-height: 1.2;
+ }
+ .content p {
+ font-size: 12px;
+ }
+}
+
+.comment-container-super-compact .comment-thread-indicator {
+ width: (vars.$xs + 3px);
+ margin-inline-start: 3px;
+}
+
#tag-manager .drag-card {
max-width: 500px;
}
}
.scroll-box > li.empty-state:last-child {
display: list-item;
+}
+
+details.section-expander summary {
+ border-top: 1px solid #DDD;
+ @include mixins.lightDark(border-color, #DDD, #000);
+ font-weight: bold;
+ font-size: 12px;
+ color: #888;
+ cursor: pointer;
+ padding-block: vars.$xs;
+}
+details.section-expander:open summary {
+ margin-bottom: vars.$s;
+}
+details.section-expander {
+ border-bottom: 1px solid #DDD;
+ @include mixins.lightDark(border-color, #DDD, #000);
}
\ No newline at end of file
max-width: 840px;
margin: 0 auto;
overflow-wrap: break-word;
+ position: relative;
.align-left {
text-align: left;
}
border-radius: 4px;
box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1);
@include mixins.lightDark(background-color, #fff, #333);
- width: 275px;
-
- &.is-page-editable {
- width: 328px;
- }
+ width: 328px;
&:before {
position: absolute;
}
input, button, a {
position: relative;
- border-radius: 0;
height: 28px;
font-size: 12px;
vertical-align: top;
border: 1px solid #DDD;
@include mixins.lightDark(border-color, #ddd, #000);
color: #666;
- width: 160px;
- z-index: 40;
- padding: 5px 10px;
+ width: auto;
+ flex: 1;
+ z-index: 58;
+ padding: 5px;
+ border-radius: 0;
}
.text-button {
@include mixins.lightDark(color, #444, #AAA);
}
.input-group .button {
line-height: 1;
- margin: 0 0 0 -4px;
+ margin-inline-start: -1px;
+ margin-block: 0;
box-shadow: none;
+ border-radius: 0;
}
a.button {
margin: 0;
}
}
+// Page inline comments
+.content-comment-highlight {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 0;
+ height: 0;
+ user-select: none;
+ pointer-events: none;
+ &:after {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background-color: var(--color-primary);
+ opacity: 0.25;
+ }
+}
+.content-comment-window {
+ font-size: vars.$fs-m;
+ line-height: 1.4;
+ position: absolute;
+ top: calc(100% + 3px);
+ left: 0;
+ z-index: 92;
+ pointer-events: all;
+ min-width: min(340px, 80vw);
+ @include mixins.lightDark(background-color, #FFF, #222);
+ box-shadow: vars.$bs-hover;
+ border-radius: 4px;
+ overflow: hidden;
+}
+.content-comment-window-actions {
+ background-color: var(--color-primary);
+ color: #FFF;
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ gap: vars.$xs;
+ button {
+ color: #FFF;
+ font-size: 12px;
+ padding: vars.$xs;
+ line-height: 1;
+ cursor: pointer;
+ }
+ button[data-action="jump"] {
+ text-decoration: underline;
+ }
+ svg {
+ fill: currentColor;
+ width: 12px;
+ }
+}
+.content-comment-window-content {
+ padding: vars.$xs vars.$s vars.$xs vars.$xs;
+ max-height: 200px;
+ overflow-y: scroll;
+}
+.content-comment-window-content .comment-reference-indicator-wrap {
+ display: none;
+}
+.content-comment-marker {
+ position: absolute;
+ right: -16px;
+ top: -16px;
+ pointer-events: all;
+ width: min(1.5em, 32px);
+ height: min(1.5em, 32px);
+ border-radius: min(calc(1.5em / 2), 32px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--color-primary);
+ box-shadow: vars.$bs-hover;
+ color: #FFF;
+ cursor: pointer;
+ z-index: 90;
+ transform: scale(1);
+ transition: transform ease-in-out 120ms;
+ svg {
+ fill: #FFF;
+ width: 80%;
+ }
+}
+.page-content [id^="bkmrk-"]:hover .content-comment-marker {
+ transform: scale(1.15);
+}
+
// Page editor sidebar toolbox
.floating-toolbox {
@include mixins.lightDark(background-color, #FFF, #222);
+{{--
+$branch CommentTreeNode
+--}}
<div class="comment-branch">
- <div class="mb-m">
- @include('comments.comment', ['comment' => $branch['comment']])
+ <div>
+ @include('comments.comment', ['comment' => $branch->comment])
</div>
<div class="flex-container-row">
<div class="comment-thread-indicator-parent">
<div class="comment-thread-indicator"></div>
</div>
<div class="comment-branch-children flex">
- @foreach($branch['children'] as $childBranch)
+ @foreach($branch->children as $childBranch)
@include('comments.comment-branch', ['branch' => $childBranch])
@endforeach
</div>
<div component="{{ $readOnly ? '' : 'page-comment' }}"
option:page-comment:comment-id="{{ $comment->id }}"
option:page-comment:comment-local-id="{{ $comment->local_id }}"
- option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}"
+ option:page-comment:archive-text="{{ $comment->archived ? trans('entities.comment_unarchive_success') : trans('entities.comment_archive_success') }}"
option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}"
option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
id="comment{{$comment->local_id}}"
@if(userCan('comment-create-all'))
<button refs="page-comment@reply-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('reply') {{ trans('common.reply') }}</button>
@endif
+ @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment)))
+ <button refs="page-comment@archive-button"
+ type="button"
+ data-is-archived="{{ $comment->archived ? 'true' : 'false' }}"
+ class="text-button text-muted hover-underline text-small p-xs">@icon('archive') {{ trans('common.' . ($comment->archived ? 'unarchive' : 'archive')) }}</button>
+ @endif
@if(userCan('comment-update', $comment))
<button refs="page-comment@edit-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('edit') {{ trans('common.edit') }}</button>
@endif
<a class="text-muted text-small" href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a>
</p>
@endif
+ @if($comment->content_ref)
+ <div class="comment-reference-indicator-wrap">
+ <a component="page-comment-reference"
+ option:page-comment-reference:reference="{{ $comment->content_ref }}"
+ option:page-comment-reference:view-comment-text="{{ trans('entities.comment_view') }}"
+ option:page-comment-reference:jump-to-thread-text="{{ trans('entities.comment_jump_to_thread') }}"
+ option:page-comment-reference:close-text="{{ trans('common.close') }}"
+ href="#">@icon('bookmark'){{ trans('entities.comment_reference') }} <span>{{ trans('entities.comment_reference_outdated') }}</span></a>
+ </div>
+ @endif
{!! $commentHtml !!}
</div>
-<section component="page-comments"
+<section components="page-comments tabs"
option:page-comments:page-id="{{ $page->id }}"
option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
- option:page-comments:count-text="{{ trans('entities.comment_count') }}"
+ option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}"
+ option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}"
option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}"
option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
- class="comments-list"
+ class="comments-list tab-container"
aria-label="{{ trans('entities.comments') }}">
- <div refs="page-comments@comment-count-bar" class="grid half left-focus v-center no-row-gap">
- <h5 refs="page-comments@comments-title">{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}</h5>
+ <div refs="page-comments@comment-count-bar" class="flex-container-row items-center">
+ <div role="tablist" class="flex">
+ <button type="button"
+ role="tab"
+ id="comment-tab-active"
+ aria-controls="comment-tab-panel-active"
+ refs="page-comments@active-tab"
+ aria-selected="true">{{ trans_choice('entities.comment_thread_count', $commentTree->activeThreadCount()) }}</button>
+ <button type="button"
+ role="tab"
+ id="comment-tab-archived"
+ aria-controls="comment-tab-panel-archived"
+ refs="page-comments@archived-tab"
+ aria-selected="false">{{ trans_choice('entities.comment_archived_count', count($commentTree->getArchived())) }}</button>
+ </div>
@if ($commentTree->empty() && userCan('comment-create-all'))
- <div class="text-m-right" refs="page-comments@add-button-container">
+ <div class="ml-m" refs="page-comments@add-button-container">
<button type="button"
refs="page-comments@add-comment-button"
- class="button outline">{{ trans('entities.comment_add') }}</button>
+ class="button outline mb-m">{{ trans('entities.comment_add') }}</button>
</div>
@endif
</div>
- <div refs="page-comments@commentContainer" class="comment-container">
- @foreach($commentTree->get() as $branch)
+ <div id="comment-tab-panel-active"
+ tabindex="0"
+ role="tabpanel"
+ aria-labelledby="comment-tab-active"
+ class="comment-container no-outline">
+ <div refs="page-comments@comment-container">
+ @foreach($commentTree->getActive() as $branch)
+ @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
+ @endforeach
+ </div>
+
+ <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
+
+ @if(userCan('comment-create-all'))
+ @include('comments.create')
+ @if (!$commentTree->empty())
+ <div refs="page-comments@addButtonContainer" class="flex-container-row">
+ <button type="button"
+ refs="page-comments@add-comment-button"
+ class="button outline ml-auto">{{ trans('entities.comment_add') }}</button>
+ </div>
+ @endif
+ @endif
+ </div>
+
+ <div refs="page-comments@archive-container"
+ id="comment-tab-panel-archived"
+ tabindex="0"
+ role="tabpanel"
+ aria-labelledby="comment-tab-archived"
+ hidden="hidden"
+ class="comment-container no-outline">
+ @foreach($commentTree->getArchived() as $branch)
@include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
@endforeach
+ <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
</div>
- @if(userCan('comment-create-all'))
- @include('comments.create')
- @if (!$commentTree->empty())
- <div refs="page-comments@addButtonContainer" class="text-right">
- <button type="button"
- refs="page-comments@add-comment-button"
- class="button outline">{{ trans('entities.comment_add') }}</button>
- </div>
- @endif
- @endif
-
@if(userCan('comment-create-all') || $commentTree->canUpdateAny())
@push('body-end')
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>
</div>
</div>
</div>
+ <div refs="page-comments@reference-row" hidden class="primary-background-light text-muted px-s py-xs">
+ <div class="grid left-focus v-center">
+ <div>
+ <a refs="page-comments@formReferenceLink" href="#">{{ trans('entities.comment_reference') }}</a>
+ </div>
+ <div class="text-right">
+ <button refs="page-comments@remove-reference-button" class="text-button">{{ trans('common.remove') }}</button>
+ </div>
+ </div>
+ </div>
<div class="content px-s pt-s">
<form refs="page-comments@form" novalidate>
tabindex="-1"
aria-label="{{ trans('entities.pages_pointer_label') }}"
class="pointer-container">
- <div class="pointer flex-container-row items-center justify-space-between p-s anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
- <div refs="pointer@mode-section" class="flex-container-row items-center gap-s">
+ <div class="pointer flex-container-row items-center justify-space-between gap-xs p-xs anim" >
+ <div refs="pointer@mode-section" class="flex flex-container-row items-center gap-xs">
<button refs="pointer@mode-toggle"
title="{{ trans('entities.pages_pointer_toggle_link') }}"
class="text-button icon px-xs">@icon('link')</button>
- <div class="input-group">
+ <div class="input-group flex flex-container-row items-center">
<input refs="pointer@link-input" aria-label="{{ trans('entities.pages_pointer_permalink') }}" readonly="readonly" type="text" id="pointer-url" placeholder="url">
- <button refs="pointer@link-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+ <button refs="pointer@link-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
</div>
</div>
- <div refs="pointer@mode-section" hidden class="flex-container-row items-center gap-s">
+ <div refs="pointer@mode-section" hidden class="flex flex-container-row items-center gap-xs">
<button refs="pointer@mode-toggle"
title="{{ trans('entities.pages_pointer_toggle_include') }}"
class="text-button icon px-xs">@icon('include')</button>
- <div class="input-group">
+ <div class="input-group flex flex-container-row items-center">
<input refs="pointer@include-input" aria-label="{{ trans('entities.pages_pointer_include_tag') }}" readonly="readonly" type="text" id="pointer-include" placeholder="include">
- <button refs="pointer@include-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+ <button refs="pointer@include-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
</div>
</div>
- @if(userCan('page-update', $page))
- <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
- class="button primary outline icon heading-edit-icon ml-s px-s" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
- @endif
+ <div>
+ @if(userCan('page-update', $page))
+ <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
+ class="button primary outline icon heading-edit-icon px-xs" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
+ @endif
+ @if($commentTree->enabled() && userCan('comment-create-all'))
+ <button type="button"
+ refs="pointer@comment-button"
+ class="button primary outline icon px-xs m-none" title="{{ trans('entities.comment_add')}}">@icon('comment')</button>
+ @endif
+ </div>
</div>
</div>
+{{--
+$comments - CommentTree
+--}}
<div refs="editor-toolbox@tab-content" data-tab-content="comments" class="toolbox-tab-content">
<h4>{{ trans('entities.comments') }}</h4>
<p class="text-muted small mb-m">
{{ trans('entities.comment_editor_explain') }}
</p>
- @foreach($comments->get() as $branch)
+ @foreach($comments->getActive() as $branch)
@include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
@endforeach
@if($comments->empty())
- <p class="italic text-muted">{{ trans('common.no_items') }}</p>
+ <p class="italic text-muted">{{ trans('entities.comment_none') }}</p>
+ @endif
+ @if($comments->archivedThreadCount() > 0)
+ <details class="section-expander mt-s">
+ <summary>{{ trans('entities.comment_archived_threads') }}</summary>
+ @foreach($comments->getArchived() as $branch)
+ @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
+ @endforeach
+ </details>
@endif
</div>
</div>
\ No newline at end of file
@include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous])
@if ($commentTree->enabled())
- @if(($previous || $next))
- <div class="px-xl print-hidden">
- <hr class="darker">
- </div>
- @endif
-
<div class="comments-container mb-l print-hidden">
@include('comments.comments', ['commentTree' => $commentTree, 'page' => $page])
<div class="clearfix"></div>
// Comments
Route::post('/comment/{pageId}', [ActivityControllers\CommentController::class, 'savePageComment']);
+ Route::put('/comment/{id}/archive', [ActivityControllers\CommentController::class, 'archive']);
+ Route::put('/comment/{id}/unarchive', [ActivityControllers\CommentController::class, 'unarchive']);
Route::put('/comment/{id}', [ActivityControllers\CommentController::class, 'update']);
Route::delete('/comment/{id}', [ActivityControllers\CommentController::class, 'destroy']);
--- /dev/null
+<?php
+
+namespace Entity;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class CommentDisplayTest extends TestCase
+{
+ public function test_reply_comments_are_nested()
+ {
+ $this->asAdmin();
+ $page = $this->entities->page();
+
+ $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+ $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl()));
+ $respHtml->assertElementCount('.comment-branch', 3);
+ $respHtml->assertElementNotExists('.comment-branch .comment-branch');
+
+ $comment = $page->comments()->first();
+ $resp = $this->postJson("/comment/$page->id", [
+ 'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
+ ]);
+ $resp->assertStatus(200);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl()));
+ $respHtml->assertElementCount('.comment-branch', 4);
+ $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
+ }
+
+ public function test_comments_are_visible_in_the_page_editor()
+ {
+ $page = $this->entities->page();
+
+ $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
+ $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
+ }
+
+ public function test_comment_creator_name_truncated()
+ {
+ [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
+
+ $pageResp = $this->asAdmin()->get($page->getUrl());
+ $pageResp->assertSee('Wolfeschlegels…');
+ }
+
+ public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
+ {
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $resp = $this->actingAs($editor)->get($page->getUrl());
+ $resp->assertSee('tinymce.min.js?', false);
+ $resp->assertSee('window.editor_translations', false);
+ $resp->assertSee('component="entity-selector"', false);
+
+ $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
+ $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
+
+ $resp = $this->actingAs($editor)->get($page->getUrl());
+ $resp->assertDontSee('tinymce.min.js?', false);
+ $resp->assertDontSee('window.editor_translations', false);
+ $resp->assertDontSee('component="entity-selector"', false);
+
+ Comment::factory()->create([
+ 'created_by' => $editor->id,
+ 'entity_type' => 'page',
+ 'entity_id' => $page->id,
+ ]);
+
+ $resp = $this->actingAs($editor)->get($page->getUrl());
+ $resp->assertSee('tinymce.min.js?', false);
+ $resp->assertSee('window.editor_translations', false);
+ $resp->assertSee('component="entity-selector"', false);
+ }
+
+ public function test_comment_displays_relative_times()
+ {
+ $page = $this->entities->page();
+ $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
+ $comment->created_at = now()->subWeek();
+ $comment->updated_at = now()->subDay();
+ $comment->save();
+
+ $pageResp = $this->asAdmin()->get($page->getUrl());
+ $html = $this->withHtml($pageResp);
+
+ // Create date shows relative time as text to user
+ $html->assertElementContains('.comment-box', 'commented 1 week ago');
+ // Updated indicator has full time as title
+ $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') . '"]', 'Updated');
+ }
+
+ public function test_comment_displays_reference_if_set()
+ {
+ $page = $this->entities->page();
+ $comment = Comment::factory()->make([
+ 'content_ref' => 'bkmrk-a:abc:4-1',
+ 'local_id' => 10,
+ ]);
+ $page->comments()->save($comment);
+
+ $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+ $html->assertElementExists('#comment10 .comment-reference-indicator-wrap a');
+ }
+
+ public function test_archived_comments_are_shown_in_their_own_container()
+ {
+ $page = $this->entities->page();
+ $comment = Comment::factory()->make(['local_id' => 44]);
+ $page->comments()->save($comment);
+
+ $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+ $html->assertElementExists('#comment-tab-panel-active #comment44');
+ $html->assertElementNotExists('#comment-tab-panel-archived .comment-box');
+
+ $comment->archived = true;
+ $comment->save();
+
+ $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+ $html->assertElementExists('#comment-tab-panel-archived #comment44.comment-box');
+ $html->assertElementNotExists('#comment-tab-panel-active #comment44');
+ }
+}
use BookStack\Entities\Models\Page;
use Tests\TestCase;
-class CommentTest extends TestCase
+class CommentStoreTest extends TestCase
{
public function test_add_comment()
{
$this->assertActivityExists(ActivityType::COMMENT_CREATE);
}
+ public function test_add_comment_stores_content_reference_only_if_format_valid()
+ {
+ $validityByRefs = [
+ 'bkmrk-my-title:4589284922:4-3' => true,
+ 'bkmrk-my-title:4589284922:' => true,
+ 'bkmrk-my-title:4589284922:abc' => false,
+ 'my-title:4589284922:' => false,
+ 'bkmrk-my-title-4589284922:' => false,
+ ];
+
+ $page = $this->entities->page();
+
+ foreach ($validityByRefs as $ref => $valid) {
+ $this->asAdmin()->postJson("/comment/$page->id", [
+ 'html' => '<p>My comment</p>',
+ 'parent_id' => null,
+ 'content_ref' => $ref,
+ ]);
+
+ if ($valid) {
+ $this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
+ } else {
+ $this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
+ }
+ }
+ }
public function test_comment_edit()
{
$this->assertActivityExists(ActivityType::COMMENT_DELETE);
}
+ public function test_comment_archive_and_unarchive()
+ {
+ $this->asAdmin();
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $page->comments()->save($comment);
+ $comment->refresh();
+
+ $this->put("/comment/$comment->id/archive");
+
+ $this->assertDatabaseHas('comments', [
+ 'id' => $comment->id,
+ 'archived' => true,
+ ]);
+
+ $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+
+ $this->put("/comment/$comment->id/unarchive");
+
+ $this->assertDatabaseHas('comments', [
+ 'id' => $comment->id,
+ 'archived' => false,
+ ]);
+
+ $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+ }
+
+ public function test_archive_endpoints_require_delete_or_edit_permissions()
+ {
+ $viewer = $this->users->viewer();
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $page->comments()->save($comment);
+ $comment->refresh();
+
+ $endpoints = ["/comment/$comment->id/archive", "/comment/$comment->id/unarchive"];
+
+ foreach ($endpoints as $endpoint) {
+ $resp = $this->actingAs($viewer)->put($endpoint);
+ $this->assertPermissionError($resp);
+ }
+
+ $this->permissions->grantUserRolePermissions($viewer, ['comment-delete-all']);
+
+ foreach ($endpoints as $endpoint) {
+ $resp = $this->actingAs($viewer)->put($endpoint);
+ $resp->assertOk();
+ }
+
+ $this->permissions->removeUserRolePermissions($viewer, ['comment-delete-all']);
+ $this->permissions->grantUserRolePermissions($viewer, ['comment-update-all']);
+
+ foreach ($endpoints as $endpoint) {
+ $resp = $this->actingAs($viewer)->put($endpoint);
+ $resp->assertOk();
+ }
+ }
+
+ public function test_non_top_level_comments_cant_be_archived_or_unarchived()
+ {
+ $this->asAdmin();
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $page->comments()->save($comment);
+ $subComment = Comment::factory()->make(['parent_id' => $comment->id]);
+ $page->comments()->save($subComment);
+ $subComment->refresh();
+
+ $resp = $this->putJson("/comment/$subComment->id/archive");
+ $resp->assertStatus(400);
+
+ $this->assertDatabaseHas('comments', [
+ 'id' => $subComment->id,
+ 'archived' => false,
+ ]);
+
+ $resp = $this->putJson("/comment/$subComment->id/unarchive");
+ $resp->assertStatus(400);
+ }
+
public function test_scripts_cannot_be_injected_via_comment_html()
{
$page = $this->entities->page();
'html' => $expected,
]);
}
-
- public function test_reply_comments_are_nested()
- {
- $this->asAdmin();
- $page = $this->entities->page();
-
- $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
- $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
-
- $respHtml = $this->withHtml($this->get($page->getUrl()));
- $respHtml->assertElementCount('.comment-branch', 3);
- $respHtml->assertElementNotExists('.comment-branch .comment-branch');
-
- $comment = $page->comments()->first();
- $resp = $this->postJson("/comment/$page->id", [
- 'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
- ]);
- $resp->assertStatus(200);
-
- $respHtml = $this->withHtml($this->get($page->getUrl()));
- $respHtml->assertElementCount('.comment-branch', 4);
- $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
- }
-
- public function test_comments_are_visible_in_the_page_editor()
- {
- $page = $this->entities->page();
-
- $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
-
- $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
- $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
- }
-
- public function test_comment_creator_name_truncated()
- {
- [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
- $page = $this->entities->page();
-
- $comment = Comment::factory()->make();
- $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
-
- $pageResp = $this->asAdmin()->get($page->getUrl());
- $pageResp->assertSee('Wolfeschlegels…');
- }
-
- public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
- {
- $editor = $this->users->editor();
- $page = $this->entities->page();
-
- $resp = $this->actingAs($editor)->get($page->getUrl());
- $resp->assertSee('tinymce.min.js?', false);
- $resp->assertSee('window.editor_translations', false);
- $resp->assertSee('component="entity-selector"', false);
-
- $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
- $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
-
- $resp = $this->actingAs($editor)->get($page->getUrl());
- $resp->assertDontSee('tinymce.min.js?', false);
- $resp->assertDontSee('window.editor_translations', false);
- $resp->assertDontSee('component="entity-selector"', false);
-
- Comment::factory()->create([
- 'created_by' => $editor->id,
- 'entity_type' => 'page',
- 'entity_id' => $page->id,
- ]);
-
- $resp = $this->actingAs($editor)->get($page->getUrl());
- $resp->assertSee('tinymce.min.js?', false);
- $resp->assertSee('window.editor_translations', false);
- $resp->assertSee('component="entity-selector"', false);
- }
-
- public function test_comment_displays_relative_times()
- {
- $page = $this->entities->page();
- $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
- $comment->created_at = now()->subWeek();
- $comment->updated_at = now()->subDay();
- $comment->save();
-
- $pageResp = $this->asAdmin()->get($page->getUrl());
- $html = $this->withHtml($pageResp);
-
- // Create date shows relative time as text to user
- $html->assertElementContains('.comment-box', 'commented 1 week ago');
- // Updated indicator has full time as title
- $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') . '"]', 'Updated');
- }
}
--- /dev/null
+<?php
+
+namespace Tests\Uploads;
+
+use BookStack\Uploads\ImageStorage;
+use Tests\TestCase;
+
+class ImageStorageTest extends TestCase
+{
+ public function test_local_image_storage_sets_755_directory_permissions()
+ {
+ if (PHP_OS_FAMILY !== 'Linux') {
+ $this->markTestSkipped('Test only works on Linux');
+ }
+
+ config()->set('filesystems.default', 'local');
+ $storage = $this->app->make(ImageStorage::class);
+ $dirToCheck = 'test-dir-perms-' . substr(md5(random_bytes(16)), 0, 6);
+
+ $disk = $storage->getDisk('gallery');
+ $disk->put("{$dirToCheck}/image.png", 'abc', true);
+
+ $expectedPath = public_path("uploads/images/{$dirToCheck}");
+ $permissionsApplied = substr(sprintf('%o', fileperms($expectedPath)), -4);
+ $this->assertEquals('0755', $permissionsApplied);
+
+ @unlink("{$expectedPath}/image.png");
+ @rmdir($expectedPath);
+ }
+}