]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'development' of github.com:BookStackApp/BookStack into development
authorDan Brown <redacted>
Mon, 14 Jul 2025 13:18:51 +0000 (14:18 +0100)
committerDan Brown <redacted>
Mon, 14 Jul 2025 13:18:51 +0000 (14:18 +0100)
50 files changed:
app/Activity/Models/Tag.php
app/Activity/Tools/TagClassGenerator.php
app/Entities/Controllers/BookController.php
app/Entities/Controllers/ChapterController.php
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/BookRepo.php
app/Entities/Repos/BookshelfRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/HierarchyTransformer.php
app/Entities/Tools/TrashCan.php
app/Permissions/JointPermissionBuilder.php
app/Permissions/PermissionsController.php
app/Permissions/PermissionsRepo.php
app/Sorting/BookSortController.php
app/Sorting/BookSorter.php
app/Util/DatabaseTransaction.php [new file with mode: 0644]
app/Util/HtmlDescriptionFilter.php
resources/js/components/dropdown.js
resources/js/components/page-comment.ts
resources/js/components/page-comments.ts
resources/js/components/tri-layout.ts [moved from resources/js/components/tri-layout.js with 57% similarity]
resources/js/components/wysiwyg-input.js [deleted file]
resources/js/components/wysiwyg-input.ts [new file with mode: 0644]
resources/js/services/dom.ts
resources/js/wysiwyg-tinymce/config.js
resources/js/wysiwyg/index.ts
resources/js/wysiwyg/nodes.ts
resources/js/wysiwyg/ui/defaults/toolbars.ts
resources/js/wysiwyg/ui/framework/core.ts
resources/js/wysiwyg/ui/framework/decorator.ts
resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts
resources/js/wysiwyg/ui/framework/manager.ts
resources/js/wysiwyg/ui/framework/modals.ts
resources/js/wysiwyg/ui/framework/toolbars.ts
resources/js/wysiwyg/ui/index.ts
resources/js/wysiwyg/utils/actions.ts
resources/sass/_editor.scss
resources/sass/_layout.scss
resources/views/books/parts/form.blade.php
resources/views/chapters/parts/form.blade.php
resources/views/comments/comment.blade.php
resources/views/comments/comments.blade.php
resources/views/entities/body-tag-classes.blade.php
resources/views/form/description-html-input.blade.php
resources/views/layouts/tri.blade.php
resources/views/shelves/parts/form.blade.php
tests/Entity/CommentDisplayTest.php
tests/Entity/CommentStoreTest.php
tests/Entity/TagTest.php

index 0af0a65ac76deda65a1d9d0570304b13333b5d09..0e7c68a270a26a122eab0739e0a8c36cc34b9b2a 100644 (file)
@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
  * @property int    $id
  * @property string $name
  * @property string $value
+ * @property int    $entity_id
+ * @property string $entity_type
  * @property int    $order
  */
 class Tag extends Model
index 1a1bd16c881060d2615ecc99b10c837392dd292f..5bcb44113d6ecaaa596a8ce6aed8da689e180910 100644 (file)
@@ -3,17 +3,15 @@
 namespace BookStack\Activity\Tools;
 
 use BookStack\Activity\Models\Tag;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
 
 class TagClassGenerator
 {
-    protected array $tags;
-
-    /**
-     * @param Tag[] $tags
-     */
-    public function __construct(array $tags)
-    {
-        $this->tags = $tags;
+    public function __construct(
+        protected Entity $entity
+    ) {
     }
 
     /**
@@ -22,14 +20,23 @@ class TagClassGenerator
     public function generate(): array
     {
         $classes = [];
+        $tags = $this->entity->tags->all();
+
+        foreach ($tags as $tag) {
+             array_push($classes, ...$this->generateClassesForTag($tag));
+        }
+
+        if ($this->entity instanceof BookChild && userCan('view', $this->entity->book)) {
+            $bookTags = $this->entity->book->tags;
+            foreach ($bookTags as $bookTag) {
+                 array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
+            }
+        }
 
-        foreach ($this->tags as $tag) {
-            $name = $this->normalizeTagClassString($tag->name);
-            $value = $this->normalizeTagClassString($tag->value);
-            $classes[] = 'tag-name-' . $name;
-            if ($value) {
-                $classes[] = 'tag-value-' . $value;
-                $classes[] = 'tag-pair-' . $name . '-' . $value;
+        if ($this->entity instanceof Page && $this->entity->chapter && userCan('view', $this->entity->chapter)) {
+            $chapterTags = $this->entity->chapter->tags;
+            foreach ($chapterTags as $chapterTag) {
+                 array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
             }
         }
 
@@ -41,6 +48,22 @@ class TagClassGenerator
         return implode(' ', $this->generate());
     }
 
+    /**
+     * @return string[]
+     */
+    protected function generateClassesForTag(Tag $tag, string $prefix = ''): array
+    {
+        $classes = [];
+        $name = $this->normalizeTagClassString($tag->name);
+        $value = $this->normalizeTagClassString($tag->value);
+        $classes[] = "{$prefix}tag-name-{$name}";
+        if ($value) {
+            $classes[] = "{$prefix}tag-value-{$value}";
+            $classes[] = "{$prefix}tag-pair-{$name}-{$value}";
+        }
+        return $classes;
+    }
+
     protected function normalizeTagClassString(string $value): string
     {
         $value = str_replace(' ', '', strtolower($value));
index b1685081a3df1dc2eb8d214791ae5215a49578ab..5d3d67f645c36afbf66599a7cabd0558096835b3 100644 (file)
@@ -18,6 +18,7 @@ use BookStack\Exceptions\NotFoundException;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\DatabaseTransaction;
 use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
@@ -263,7 +264,9 @@ class BookController extends Controller
         $this->checkPermission('bookshelf-create-all');
         $this->checkPermission('book-create-all');
 
-        $shelf = $transformer->transformBookToShelf($book);
+        $shelf = (new DatabaseTransaction(function () use ($book, $transformer) {
+            return $transformer->transformBookToShelf($book);
+        }))->run();
 
         return redirect($shelf->getUrl());
     }
index 4274589e26055c5d1e378156f7d7b0ccd76f87bc..677745500c39ab065ca3cde843852cb35fc98950 100644 (file)
@@ -18,6 +18,7 @@ use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\Controller;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
@@ -269,7 +270,9 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-delete', $chapter);
         $this->checkPermission('book-create-all');
 
-        $book = $transformer->transformChapterToBook($chapter);
+        $book = (new DatabaseTransaction(function () use ($chapter, $transformer) {
+            return $transformer->transformChapterToBook($chapter);
+        }))->run();
 
         return redirect($book->getUrl());
     }
index 151d5b0555bbc5fc884c23b8612d10e73884b36d..ac5a44e679dbc70f9d9c8563231aeea5ed06e22b 100644 (file)
@@ -77,7 +77,6 @@ class BaseRepo
             $entity->touch();
         }
 
-        $entity->rebuildPermissions();
         $entity->indexForSearch();
         $this->referenceStore->updateForEntity($entity);
 
@@ -139,7 +138,7 @@ class BaseRepo
 
     /**
      * Sort the parent of the given entity, if any auto sort actions are set for it.
-     * Typical ran during create/update/insert events.
+     * Typically ran during create/update/insert events.
      */
     public function sortParent(Entity $entity): void
     {
index 92e6a81c337fcc45dbe7d15c477082454526adf2..6d28d5d6aabe1796a0f219d641dc5abf816badd3 100644 (file)
@@ -10,6 +10,7 @@ use BookStack\Exceptions\ImageUploadException;
 use BookStack\Facades\Activity;
 use BookStack\Sorting\SortRule;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Http\UploadedFile;
 
@@ -28,19 +29,22 @@ class BookRepo
      */
     public function create(array $input): Book
     {
-        $book = new Book();
-        $this->baseRepo->create($book, $input);
-        $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
-        $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
-        Activity::add(ActivityType::BOOK_CREATE, $book);
+        return (new DatabaseTransaction(function () use ($input) {
+            $book = new Book();
 
-        $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
-        if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
-            $book->sort_rule_id = $defaultBookSortSetting;
-            $book->save();
-        }
+            $this->baseRepo->create($book, $input);
+            $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
+            $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
+            Activity::add(ActivityType::BOOK_CREATE, $book);
 
-        return $book;
+            $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
+            if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
+                $book->sort_rule_id = $defaultBookSortSetting;
+                $book->save();
+            }
+
+            return $book;
+        }))->run();
     }
 
     /**
index a00349ef1aeaec8cc79949932e4132ea14bc8d73..8e60f58c42f0c4d1c735b90fe41b962e32347765 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Facades\Activity;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class BookshelfRepo
@@ -23,13 +24,14 @@ class BookshelfRepo
      */
     public function create(array $input, array $bookIds): Bookshelf
     {
-        $shelf = new Bookshelf();
-        $this->baseRepo->create($shelf, $input);
-        $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
-        $this->updateBooks($shelf, $bookIds);
-        Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
-
-        return $shelf;
+        return (new DatabaseTransaction(function () use ($input, $bookIds) {
+            $shelf = new Bookshelf();
+            $this->baseRepo->create($shelf, $input);
+            $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
+            $this->updateBooks($shelf, $bookIds);
+            Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
+            return $shelf;
+        }))->run();
     }
 
     /**
index fdf2de4e20235b81d39d3fed3ed7b837cc473cc4..6503e63cfafc5912a886131ab089b169358ecbeb 100644 (file)
@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class ChapterRepo
@@ -27,16 +28,18 @@ class ChapterRepo
      */
     public function create(array $input, Book $parentBook): Chapter
     {
-        $chapter = new Chapter();
-        $chapter->book_id = $parentBook->id;
-        $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
-        $this->baseRepo->create($chapter, $input);
-        $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
-        Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
-
-        $this->baseRepo->sortParent($chapter);
-
-        return $chapter;
+        return (new DatabaseTransaction(function () use ($input, $parentBook) {
+            $chapter = new Chapter();
+            $chapter->book_id = $parentBook->id;
+            $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
+            $this->baseRepo->create($chapter, $input);
+            $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
+            Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
+
+            $this->baseRepo->sortParent($chapter);
+
+            return $chapter;
+        }))->run();
     }
 
     /**
@@ -88,12 +91,14 @@ class ChapterRepo
             throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
         }
 
-        $chapter->changeBook($parent->id);
-        $chapter->rebuildPermissions();
-        Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
+        return (new DatabaseTransaction(function () use ($chapter, $parent) {
+            $chapter->changeBook($parent->id);
+            $chapter->rebuildPermissions();
+            Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
 
-        $this->baseRepo->sortParent($chapter);
+            $this->baseRepo->sortParent($chapter);
 
-        return $parent;
+            return $parent;
+        }))->run();
     }
 }
index c3be6d826a26dd87a3eea69aa9646abadcd664ca..63e8b8370ee046ad20b14de6b122ce18e050ecd6 100644 (file)
@@ -18,6 +18,7 @@ use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
 use BookStack\References\ReferenceStore;
 use BookStack\References\ReferenceUpdater;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class PageRepo
@@ -61,8 +62,10 @@ class PageRepo
             ]);
         }
 
-        $page->save();
-        $page->refresh()->rebuildPermissions();
+        (new DatabaseTransaction(function () use ($page) {
+            $page->save();
+            $page->refresh()->rebuildPermissions();
+        }))->run();
 
         return $page;
     }
@@ -72,26 +75,29 @@ class PageRepo
      */
     public function publishDraft(Page $draft, array $input): Page
     {
-        $draft->draft = false;
-        $draft->revision_count = 1;
-        $draft->priority = $this->getNewPriority($draft);
-        $this->updateTemplateStatusAndContentFromInput($draft, $input);
-        $this->baseRepo->update($draft, $input);
-
-        $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
-        $this->revisionRepo->storeNewForPage($draft, $summary);
-        $draft->refresh();
-
-        Activity::add(ActivityType::PAGE_CREATE, $draft);
-        $this->baseRepo->sortParent($draft);
-
-        return $draft;
+        return (new DatabaseTransaction(function () use ($draft, $input) {
+            $draft->draft = false;
+            $draft->revision_count = 1;
+            $draft->priority = $this->getNewPriority($draft);
+            $this->updateTemplateStatusAndContentFromInput($draft, $input);
+            $this->baseRepo->update($draft, $input);
+            $draft->rebuildPermissions();
+
+            $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
+            $this->revisionRepo->storeNewForPage($draft, $summary);
+            $draft->refresh();
+
+            Activity::add(ActivityType::PAGE_CREATE, $draft);
+            $this->baseRepo->sortParent($draft);
+
+            return $draft;
+        }))->run();
     }
 
     /**
      * Directly update the content for the given page from the provided input.
      * Used for direct content access in a way that performs required changes
-     * (Search index & reference regen) without performing an official update.
+     * (Search index and reference regen) without performing an official update.
      */
     public function setContentFromInput(Page $page, array $input): void
     {
@@ -116,7 +122,7 @@ class PageRepo
         $page->revision_count++;
         $page->save();
 
-        // Remove all update drafts for this user & page.
+        // Remove all update drafts for this user and page.
         $this->revisionRepo->deleteDraftsForCurrentUser($page);
 
         // Save a revision after updating
@@ -269,16 +275,18 @@ class PageRepo
             throw new PermissionsException('User does not have permission to create a page within the new parent');
         }
 
-        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
-        $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
-        $page->changeBook($newBookId);
-        $page->rebuildPermissions();
+        return (new DatabaseTransaction(function () use ($page, $parent) {
+            $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
+            $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
+            $page->changeBook($newBookId);
+            $page->rebuildPermissions();
 
-        Activity::add(ActivityType::PAGE_MOVE, $page);
+            Activity::add(ActivityType::PAGE_MOVE, $page);
 
-        $this->baseRepo->sortParent($page);
+            $this->baseRepo->sortParent($page);
 
-        return $parent;
+            return $parent;
+        }))->run();
     }
 
     /**
index cd6c548fe581b1f9179e5332dd3174461ea4dbe1..b0d8880f402ecb0efd2a1241cd7d750312061d4e 100644 (file)
@@ -13,17 +13,12 @@ use BookStack\Facades\Activity;
 
 class HierarchyTransformer
 {
-    protected BookRepo $bookRepo;
-    protected BookshelfRepo $shelfRepo;
-    protected Cloner $cloner;
-    protected TrashCan $trashCan;
-
-    public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan)
-    {
-        $this->bookRepo = $bookRepo;
-        $this->shelfRepo = $shelfRepo;
-        $this->cloner = $cloner;
-        $this->trashCan = $trashCan;
+    public function __construct(
+        protected BookRepo $bookRepo,
+        protected BookshelfRepo $shelfRepo,
+        protected Cloner $cloner,
+        protected TrashCan $trashCan
+    ) {
     }
 
     /**
index 39c982cdc92a25219b0df21bfaadac9c4b52a372..5e8a9371942ea246098bf066851956401e942c1c 100644 (file)
@@ -15,6 +15,7 @@ use BookStack\Exceptions\NotifyException;
 use BookStack\Facades\Activity;
 use BookStack\Uploads\AttachmentService;
 use BookStack\Uploads\ImageService;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Carbon;
@@ -357,25 +358,26 @@ class TrashCan
 
     /**
      * Destroy the given entity.
+     * Returns the number of total entities destroyed in the operation.
      *
      * @throws Exception
      */
     public function destroyEntity(Entity $entity): int
     {
-        if ($entity instanceof Page) {
-            return $this->destroyPage($entity);
-        }
-        if ($entity instanceof Chapter) {
-            return $this->destroyChapter($entity);
-        }
-        if ($entity instanceof Book) {
-            return $this->destroyBook($entity);
-        }
-        if ($entity instanceof Bookshelf) {
-            return $this->destroyShelf($entity);
-        }
+        $result = (new DatabaseTransaction(function () use ($entity) {
+            if ($entity instanceof Page) {
+                return $this->destroyPage($entity);
+            } else if ($entity instanceof Chapter) {
+                return $this->destroyChapter($entity);
+            } else if ($entity instanceof Book) {
+                return $this->destroyBook($entity);
+            } else if ($entity instanceof Bookshelf) {
+                return $this->destroyShelf($entity);
+            }
+            return null;
+        }))->run();
 
-        return 0;
+        return $result ?? 0;
     }
 
     /**
index c2922cdc9611481b1ba58a64d968f6e9d6dd18a8..56b22ad1604fc96fc18b2fe2265fc22f72a49c24 100644 (file)
@@ -29,7 +29,7 @@ class JointPermissionBuilder
     /**
      * Re-generate all entity permission from scratch.
      */
-    public function rebuildForAll()
+    public function rebuildForAll(): void
     {
         JointPermission::query()->truncate();
 
@@ -51,7 +51,7 @@ class JointPermissionBuilder
     /**
      * Rebuild the entity jointPermissions for a particular entity.
      */
-    public function rebuildForEntity(Entity $entity)
+    public function rebuildForEntity(Entity $entity): void
     {
         $entities = [$entity];
         if ($entity instanceof Book) {
@@ -119,7 +119,7 @@ class JointPermissionBuilder
     /**
      * Build joint permissions for the given book and role combinations.
      */
-    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
+    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void
     {
         $entities = clone $books;
 
@@ -143,7 +143,7 @@ class JointPermissionBuilder
     /**
      * Rebuild the entity jointPermissions for a collection of entities.
      */
-    protected function buildJointPermissionsForEntities(array $entities)
+    protected function buildJointPermissionsForEntities(array $entities): void
     {
         $roles = Role::query()->get()->values()->all();
         $this->deleteManyJointPermissionsForEntities($entities);
@@ -155,21 +155,19 @@ class JointPermissionBuilder
      *
      * @param Entity[] $entities
      */
-    protected function deleteManyJointPermissionsForEntities(array $entities)
+    protected function deleteManyJointPermissionsForEntities(array $entities): void
     {
         $simpleEntities = $this->entitiesToSimpleEntities($entities);
         $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
 
-        DB::transaction(function () use ($idsByType) {
-            foreach ($idsByType as $type => $ids) {
-                foreach (array_chunk($ids, 1000) as $idChunk) {
-                    DB::table('joint_permissions')
-                        ->where('entity_type', '=', $type)
-                        ->whereIn('entity_id', $idChunk)
-                        ->delete();
-                }
+        foreach ($idsByType as $type => $ids) {
+            foreach (array_chunk($ids, 1000) as $idChunk) {
+                DB::table('joint_permissions')
+                    ->where('entity_type', '=', $type)
+                    ->whereIn('entity_id', $idChunk)
+                    ->delete();
             }
-        });
+        }
     }
 
     /**
@@ -195,7 +193,7 @@ class JointPermissionBuilder
      * @param Entity[] $originalEntities
      * @param Role[]   $roles
      */
-    protected function createManyJointPermissions(array $originalEntities, array $roles)
+    protected function createManyJointPermissions(array $originalEntities, array $roles): void
     {
         $entities = $this->entitiesToSimpleEntities($originalEntities);
         $jointPermissions = [];
@@ -225,11 +223,9 @@ class JointPermissionBuilder
             }
         }
 
-        DB::transaction(function () use ($jointPermissions) {
-            foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
-                DB::table('joint_permissions')->insert($jointPermissionChunk);
-            }
-        });
+        foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
+            DB::table('joint_permissions')->insert($jointPermissionChunk);
+        }
     }
 
     /**
index 5d2035870bccd79ba4328ec973a18a4140201475..9dcfe242ec054285773dd7d1199d71d1d2d6ee9e 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Http\Controller;
 use BookStack\Permissions\Models\EntityPermission;
 use BookStack\Users\Models\Role;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 
 class PermissionsController extends Controller
@@ -40,7 +41,9 @@ class PermissionsController extends Controller
         $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
         $this->checkOwnablePermission('restrictions-manage', $page);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+        (new DatabaseTransaction(function () use ($page, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.pages_permissions_success'));
 
@@ -70,7 +73,9 @@ class PermissionsController extends Controller
         $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('restrictions-manage', $chapter);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+        (new DatabaseTransaction(function () use ($chapter, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
 
@@ -100,7 +105,9 @@ class PermissionsController extends Controller
         $book = $this->queries->books->findVisibleBySlugOrFail($slug);
         $this->checkOwnablePermission('restrictions-manage', $book);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+        (new DatabaseTransaction(function () use ($book, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.books_permissions_updated'));
 
@@ -130,7 +137,9 @@ class PermissionsController extends Controller
         $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+        (new DatabaseTransaction(function () use ($shelf, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
 
@@ -145,7 +154,10 @@ class PermissionsController extends Controller
         $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
+        $updateCount = (new DatabaseTransaction(function () use ($shelf) {
+            return $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
+        }))->run();
+
         $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
 
         return redirect($shelf->getUrl());
index b41612968b4692629a9a7392f563416c59b741c3..6ced7b7511ce46c2b478fac65690ef6a43caf547 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
 use BookStack\Permissions\Models\RolePermission;
 use BookStack\Users\Models\Role;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Database\Eloquent\Collection;
 
@@ -48,38 +49,42 @@ class PermissionsRepo
      */
     public function saveNewRole(array $roleData): Role
     {
-        $role = new Role($roleData);
-        $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
-        $role->save();
+        return (new DatabaseTransaction(function () use ($roleData) {
+            $role = new Role($roleData);
+            $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
+            $role->save();
 
-        $permissions = $roleData['permissions'] ?? [];
-        $this->assignRolePermissions($role, $permissions);
-        $this->permissionBuilder->rebuildForRole($role);
+            $permissions = $roleData['permissions'] ?? [];
+            $this->assignRolePermissions($role, $permissions);
+            $this->permissionBuilder->rebuildForRole($role);
 
-        Activity::add(ActivityType::ROLE_CREATE, $role);
+            Activity::add(ActivityType::ROLE_CREATE, $role);
 
-        return $role;
+            return $role;
+        }))->run();
     }
 
     /**
      * Updates an existing role.
-     * Ensures Admin system role always have core permissions.
+     * Ensures the Admin system role always has core permissions.
      */
     public function updateRole($roleId, array $roleData): Role
     {
         $role = $this->getRoleById($roleId);
 
-        if (isset($roleData['permissions'])) {
-            $this->assignRolePermissions($role, $roleData['permissions']);
-        }
+        return (new DatabaseTransaction(function () use ($role, $roleData) {
+            if (isset($roleData['permissions'])) {
+                $this->assignRolePermissions($role, $roleData['permissions']);
+            }
 
-        $role->fill($roleData);
-        $role->save();
-        $this->permissionBuilder->rebuildForRole($role);
+            $role->fill($roleData);
+            $role->save();
+            $this->permissionBuilder->rebuildForRole($role);
 
-        Activity::add(ActivityType::ROLE_UPDATE, $role);
+            Activity::add(ActivityType::ROLE_UPDATE, $role);
 
-        return $role;
+            return $role;
+        }))->run();
     }
 
     /**
@@ -114,7 +119,7 @@ class PermissionsRepo
     /**
      * Delete a role from the system.
      * Check it's not an admin role or set as default before deleting.
-     * If a migration Role ID is specified the users assign to the current role
+     * If a migration Role ID is specified, the users assigned to the current role
      * will be added to the role of the specified id.
      *
      * @throws PermissionsException
@@ -131,17 +136,19 @@ class PermissionsRepo
             throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
         }
 
-        if ($migrateRoleId !== 0) {
-            $newRole = Role::query()->find($migrateRoleId);
-            if ($newRole) {
-                $users = $role->users()->pluck('id')->toArray();
-                $newRole->users()->sync($users);
+        (new DatabaseTransaction(function () use ($migrateRoleId, $role) {
+            if ($migrateRoleId !== 0) {
+                $newRole = Role::query()->find($migrateRoleId);
+                if ($newRole) {
+                    $users = $role->users()->pluck('id')->toArray();
+                    $newRole->users()->sync($users);
+                }
             }
-        }
 
-        $role->entityPermissions()->delete();
-        $role->jointPermissions()->delete();
-        Activity::add(ActivityType::ROLE_DELETE, $role);
-        $role->delete();
+            $role->entityPermissions()->delete();
+            $role->jointPermissions()->delete();
+            Activity::add(ActivityType::ROLE_DELETE, $role);
+            $role->delete();
+        }))->run();
     }
 }
index 479d1972440dceca53e3c7291fa696d54c92b65b..d70d0e6565acc33f5113fdb352cc6c76252fd997 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 
 class BookSortController extends Controller
@@ -55,16 +56,18 @@ class BookSortController extends Controller
 
         // Sort via map
         if ($request->filled('sort-tree')) {
-            $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
-            $booksInvolved = $sorter->sortUsingMap($sortMap);
+            (new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) {
+                $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
+                $booksInvolved = $sorter->sortUsingMap($sortMap);
 
-            // Rebuild permissions and add activity for involved books.
-            foreach ($booksInvolved as $bookInvolved) {
-                Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
-                if ($bookInvolved->id === $book->id) {
-                    $loggedActivityForBook = true;
+                // Add activity for involved books.
+                foreach ($booksInvolved as $bookInvolved) {
+                    Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
+                    if ($bookInvolved->id === $book->id) {
+                        $loggedActivityForBook = true;
+                    }
                 }
-            }
+            }))->run();
         }
 
         if ($request->filled('auto-sort')) {
index 6710f070ad6669d7fa5012a8f3f33c734a3d7a33..cf41a6a94d0e8b3d96ce01798724b995baa10fde 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace BookStack\Sorting;
 
-use BookStack\App\Model;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Chapter;
diff --git a/app/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php
new file mode 100644 (file)
index 0000000..e36bd2e
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+namespace BookStack\Util;
+
+use Closure;
+use Illuminate\Support\Facades\DB;
+use Throwable;
+
+/**
+ * Run the given code within a database transactions.
+ * Wraps Laravel's own transaction method, but sets a specific runtime isolation method.
+ * This sets a session level since this won't cause issues if already within a transaction,
+ * and this should apply to the next transactions anyway.
+ *
+ * "READ COMMITTED" ensures that changes from other transactions can be read within
+ * a transaction, even if started afterward (and for example, it was blocked by the initial
+ * transaction). This is quite important for things like permission generation, where we would
+ * want to consider the changes made by other committed transactions by the time we come to
+ * regenerate permission access.
+ *
+ * @throws Throwable
+ * @template TReturn of mixed
+ */
+class DatabaseTransaction
+{
+    /**
+     * @param  (Closure(static): TReturn)  $callback
+     */
+    public function __construct(
+        protected Closure $callback
+    ) {
+    }
+
+    /**
+     * @return TReturn
+     */
+    public function run(): mixed
+    {
+        DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED');
+        return DB::transaction($this->callback);
+    }
+}
index cb091b869f8fc9a2ec5d4a9ba644387612f82c95..d4f7d2c8fa258fc3c4489099e43202814153e1d5 100644 (file)
@@ -4,7 +4,6 @@ namespace BookStack\Util;
 
 use DOMAttr;
 use DOMElement;
-use DOMNamedNodeMap;
 use DOMNode;
 
 /**
@@ -25,6 +24,7 @@ class HtmlDescriptionFilter
         'ul' => [],
         'li' => [],
         'strong' => [],
+        'span' => [],
         'em' => [],
         'br' => [],
     ];
@@ -59,7 +59,6 @@ class HtmlDescriptionFilter
             return;
         }
 
-        /** @var DOMNamedNodeMap $attrs */
         $attrs = $element->attributes;
         for ($i = $attrs->length - 1; $i >= 0; $i--) {
             /** @var DOMAttr $attr */
@@ -70,7 +69,8 @@ class HtmlDescriptionFilter
             }
         }
 
-        foreach ($element->childNodes as $child) {
+        $childNodes = [...$element->childNodes];
+        foreach ($childNodes as $child) {
             if ($child instanceof DOMElement) {
                 static::filterElement($child);
             }
index 5dd5dd93b013023ebf466ef021e9237dd1b57ce7..d2b044ee1ca871a96136415442b8e74add02c817 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom.ts';
+import {findClosestScrollContainer, onSelect} from '../services/dom.ts';
 import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 import {Component} from './component';
 
@@ -33,7 +33,8 @@ export class Dropdown extends Component {
         const menuOriginalRect = this.menu.getBoundingClientRect();
         let heightOffset = 0;
         const toggleHeight = this.toggle.getBoundingClientRect().height;
-        const dropUpwards = menuOriginalRect.bottom > window.innerHeight;
+        const containerBounds = findClosestScrollContainer(this.menu).getBoundingClientRect();
+        const dropUpwards = menuOriginalRect.bottom > containerBounds.bottom;
         const containerRect = this.container.getBoundingClientRect();
 
         // If enabled, Move to body to prevent being trapped within scrollable sections
index a0bb7a55bf8985c3c54cf8358e7dc4e3b67f6dd9..8334ebb8a092ce25bc1783d2a9930df6a215cffe 100644 (file)
@@ -1,8 +1,9 @@
 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";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
+import {el} from "../wysiwyg/utils/dom";
 
 export interface PageCommentReplyEventData {
     id: string; // ID of comment being replied to
@@ -21,8 +22,7 @@ export class PageComment extends Component {
     protected updatedText!: string;
     protected archiveText!: string;
 
-    protected wysiwygEditor: any = null;
-    protected wysiwygLanguage!: string;
+    protected wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
     protected wysiwygTextDirection!: string;
 
     protected container!: HTMLElement;
@@ -44,7 +44,6 @@ export class PageComment extends Component {
         this.archiveText = this.$opts.archiveText;
 
         // Editor reference and text options
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
         this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
 
         // Element references
@@ -90,7 +89,7 @@ export class PageComment extends Component {
         this.form.toggleAttribute('hidden', !show);
     }
 
-    protected startEdit() : void {
+    protected async startEdit(): Promise<void> {
         this.toggleEditMode(true);
 
         if (this.wysiwygEditor) {
@@ -98,21 +97,20 @@ export class PageComment extends Component {
             return;
         }
 
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.input,
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const editorContent = this.input.value;
+        const container = el('div', {class: 'comment-editor-container'});
+        this.input.parentElement?.appendChild(container);
+        this.input.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, {
             darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.wysiwygTextDirection,
-            drawioUrl: '',
-            pageId: 0,
-            translations: {},
-            translationMap: (window as unknown as Record<string, Object>).editor_translations,
+            textDirection: this.$opts.textDirection,
+            translations: (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);
-        });
+        this.wysiwygEditor.focus();
     }
 
     protected async update(event: Event): Promise<void> {
@@ -121,7 +119,7 @@ export class PageComment extends Component {
         this.form.toggleAttribute('hidden', true);
 
         const reqData = {
-            html: this.wysiwygEditor.getContent(),
+            html: await this.wysiwygEditor?.getContentAsHtml() || '',
         };
 
         try {
index 5c1cd014c54d8a67a7012e85a693ec0825f9c690..a1eeda1f9d9c28ec7e5f0cf00553b3ff5300cde8 100644 (file)
@@ -1,10 +1,11 @@
 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";
+import {el} from "../wysiwyg/utils/dom";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
 
 export class PageComments extends Component {
 
@@ -28,9 +29,8 @@ export class PageComments extends Component {
     private hideFormButton!: HTMLElement;
     private removeReplyToButton!: HTMLElement;
     private removeReferenceButton!: HTMLElement;
-    private wysiwygLanguage!: string;
     private wysiwygTextDirection!: string;
-    private wysiwygEditor: any = null;
+    private wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
     private createdText!: string;
     private countText!: string;
     private archivedCountText!: string;
@@ -63,7 +63,6 @@ export class PageComments extends Component {
         this.removeReferenceButton = this.$refs.removeReferenceButton;
 
         // WYSIWYG options
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
         this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
 
         // Translations
@@ -107,7 +106,7 @@ export class PageComments extends Component {
         }
     }
 
-    protected saveComment(event: SubmitEvent): void {
+    protected async saveComment(event: SubmitEvent): Promise<void> {
         event.preventDefault();
         event.stopPropagation();
 
@@ -117,7 +116,7 @@ export class PageComments extends Component {
         this.form.toggleAttribute('hidden', true);
 
         const reqData = {
-            html: this.wysiwygEditor.getContent(),
+            html: (await this.wysiwygEditor?.getContentAsHtml()) || '',
             parent_id: this.parentId || null,
             content_ref: this.contentReference,
         };
@@ -189,27 +188,25 @@ export class PageComments extends Component {
         this.addButtonContainer.toggleAttribute('hidden', false);
     }
 
-    protected loadEditor(): void {
+    protected async loadEditor(): Promise<void> {
         if (this.wysiwygEditor) {
             this.wysiwygEditor.focus();
             return;
         }
 
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.formInput,
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const container = el('div', {class: 'comment-editor-container'});
+        this.formInput.parentElement?.appendChild(container);
+        this.formInput.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '<p></p>', {
             darkMode: document.documentElement.classList.contains('dark-mode'),
             textDirection: this.wysiwygTextDirection,
-            drawioUrl: '',
-            pageId: 0,
-            translations: {},
-            translationMap: (window as unknown as Record<string, Object>).editor_translations,
+            translations: (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);
-        });
+        this.wysiwygEditor.focus();
     }
 
     protected removeEditor(): void {
similarity index 57%
rename from resources/js/components/tri-layout.js
rename to resources/js/components/tri-layout.ts
index be9388e8d4615f5919a81068ff57915bc9d52ae9..40a2d36910437efa98fde9edd1410ab0e438e88f 100644 (file)
@@ -1,18 +1,22 @@
 import {Component} from './component';
 
 export class TriLayout extends Component {
-
-    setup() {
+    private container!: HTMLElement;
+    private tabs!: HTMLElement[];
+    private sidebarScrollContainers!: HTMLElement[];
+
+    private lastLayoutType = 'none';
+    private onDestroy: (()=>void)|null = null;
+    private scrollCache: Record<string, number> = {
+        content: 0,
+        info: 0,
+    };
+    private lastTabShown = 'content';
+
+    setup(): void {
         this.container = this.$refs.container;
         this.tabs = this.$manyRefs.tab;
-
-        this.lastLayoutType = 'none';
-        this.onDestroy = null;
-        this.scrollCache = {
-            content: 0,
-            info: 0,
-        };
-        this.lastTabShown = 'content';
+        this.sidebarScrollContainers = this.$manyRefs.sidebarScrollContainer;
 
         // Bind any listeners
         this.mobileTabClick = this.mobileTabClick.bind(this);
@@ -22,9 +26,11 @@ export class TriLayout extends Component {
         window.addEventListener('resize', () => {
             this.updateLayout();
         }, {passive: true});
+
+        this.setupSidebarScrollHandlers();
     }
 
-    updateLayout() {
+    updateLayout(): void {
         let newLayout = 'tablet';
         if (window.innerWidth <= 1000) newLayout = 'mobile';
         if (window.innerWidth > 1400) newLayout = 'desktop';
@@ -56,16 +62,15 @@ export class TriLayout extends Component {
         };
     }
 
-    setupDesktop() {
+    setupDesktop(): void {
         //
     }
 
     /**
      * Action to run when the mobile info toggle bar is clicked/tapped
-     * @param event
      */
-    mobileTabClick(event) {
-        const {tab} = event.target.dataset;
+    mobileTabClick(event: MouseEvent): void {
+        const tab = (event.target as HTMLElement).dataset.tab || '';
         this.showTab(tab);
     }
 
@@ -73,16 +78,14 @@ export class TriLayout extends Component {
      * Show the content tab.
      * Used by the page-display component.
      */
-    showContent() {
+    showContent(): void {
         this.showTab('content', false);
     }
 
     /**
      * Show the given tab
-     * @param {String} tabName
-     * @param {Boolean }scroll
      */
-    showTab(tabName, scroll = true) {
+    showTab(tabName: string, scroll: boolean = true): void {
         this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop;
 
         // Set tab status
@@ -97,7 +100,7 @@ export class TriLayout extends Component {
 
         // Set the scroll position from cache
         if (scroll) {
-            const pageHeader = document.querySelector('header');
+            const pageHeader = document.querySelector('header') as HTMLElement;
             const defaultScrollTop = pageHeader.getBoundingClientRect().bottom;
             document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
             setTimeout(() => {
@@ -108,4 +111,30 @@ export class TriLayout extends Component {
         this.lastTabShown = tabName;
     }
 
+    setupSidebarScrollHandlers(): void {
+        for (const sidebar of this.sidebarScrollContainers) {
+            sidebar.addEventListener('scroll', () => this.handleSidebarScroll(sidebar), {
+                passive: true,
+            });
+            this.handleSidebarScroll(sidebar);
+        }
+
+        window.addEventListener('resize', () => {
+            for (const sidebar of this.sidebarScrollContainers) {
+                this.handleSidebarScroll(sidebar);
+            }
+        });
+    }
+
+    handleSidebarScroll(sidebar: HTMLElement): void {
+        const scrollable = sidebar.clientHeight !== sidebar.scrollHeight;
+        const atTop = sidebar.scrollTop === 0;
+        const atBottom = (sidebar.scrollTop + sidebar.clientHeight) === sidebar.scrollHeight;
+
+        if (sidebar.parentElement) {
+            sidebar.parentElement.classList.toggle('scroll-away-from-top', !atTop && scrollable);
+            sidebar.parentElement.classList.toggle('scroll-away-from-bottom', !atBottom && scrollable);
+        }
+    }
+
 }
diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js
deleted file mode 100644 (file)
index aa21a63..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-import {Component} from './component';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class WysiwygInput extends Component {
-
-    setup() {
-        this.elem = this.$el;
-
-        const config = buildForInput({
-            language: this.$opts.language,
-            containerElement: this.elem,
-            darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.$opts.textDirection,
-            translations: {},
-            translationMap: window.editor_translations,
-        });
-
-        window.tinymce.init(config).then(editors => {
-            this.editor = editors[0];
-        });
-    }
-
-}
diff --git a/resources/js/components/wysiwyg-input.ts b/resources/js/components/wysiwyg-input.ts
new file mode 100644 (file)
index 0000000..1d914ad
--- /dev/null
@@ -0,0 +1,32 @@
+import {Component} from './component';
+import {el} from "../wysiwyg/utils/dom";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
+
+export class WysiwygInput extends Component {
+    private elem!: HTMLTextAreaElement;
+    private wysiwygEditor!: SimpleWysiwygEditorInterface;
+    private textDirection!: string;
+
+    async setup() {
+        this.elem = this.$el as HTMLTextAreaElement;
+        this.textDirection = this.$opts.textDirection;
+
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const container = el('div', {class: 'basic-editor-container'});
+        this.elem.parentElement?.appendChild(container);
+        this.elem.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, this.elem.value, {
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.textDirection,
+            translations: (window as unknown as Record<string, Object>).editor_translations,
+        });
+
+        this.wysiwygEditor.onChange(() => {
+            this.wysiwygEditor.getContentAsHtml().then(html => {
+                this.elem.value = html;
+            });
+        });
+    }
+}
index c3817536c85422c8d0e480cbd05f267be3f6633f..8696fe81639c84aea40294dc9b3c1db376ca129f 100644 (file)
@@ -256,4 +256,22 @@ export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number)
 export function hashElement(element: HTMLElement): string {
     const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
     return cyrb53(normalisedElemText);
+}
+
+/**
+ * Find the closest scroll container parent for the given element
+ * otherwise will default to the body element.
+ */
+export function findClosestScrollContainer(start: HTMLElement): HTMLElement {
+    let el: HTMLElement|null = start;
+    do {
+        const computed = window.getComputedStyle(el);
+        if (computed.overflowY === 'scroll') {
+            return el;
+        }
+
+        el = el.parentElement;
+    } while (el);
+
+    return document.body;
 }
\ No newline at end of file
index 1666aa50066af25f3ab38b12a996cce2148672bb..c0cfd37d97b21eb8186415e9da9d00b50cdc9329 100644 (file)
@@ -310,54 +310,6 @@ export function buildForEditor(options) {
     };
 }
 
-/**
- * @param {WysiwygConfigOptions} options
- * @return {RawEditorOptions}
- */
-export function buildForInput(options) {
-    // Set language
-    window.tinymce.addI18n(options.language, options.translationMap);
-
-    // BookStack Version
-    const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
-
-    // Return config object
-    return {
-        width: '100%',
-        height: '185px',
-        target: options.containerElement,
-        cache_suffix: `?version=${version}`,
-        content_css: [
-            window.baseUrl('/dist/styles.css'),
-        ],
-        branding: false,
-        skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
-        body_class: 'wysiwyg-input',
-        browser_spellcheck: true,
-        relative_urls: false,
-        language: options.language,
-        directionality: options.textDirection,
-        remove_script_host: false,
-        document_base_url: window.baseUrl('/'),
-        end_container_on_empty_block: true,
-        remove_trailing_brs: false,
-        statusbar: false,
-        menubar: false,
-        plugins: 'link autolink lists',
-        contextmenu: false,
-        toolbar: 'bold italic link bullist numlist',
-        content_style: getContentStyle(options),
-        file_picker_types: 'file',
-        valid_elements: 'p,a[href|title|target],ol,ul,li,strong,em,br',
-        file_picker_callback: filePickerCallback,
-        init_instance_callback(editor) {
-            addCustomHeadContent(editor.getDoc());
-
-            editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode);
-        },
-    };
-}
-
 /**
  * @typedef {Object} WysiwygConfigOptions
  * @property {Element} containerElement
index 7ecf91d230e4fa1671108306a611ee0f0e4f12c7..f572f9de5ec9da58fc890b93b8efced42de8ab7d 100644 (file)
@@ -1,75 +1,76 @@
-import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical';
+import {createEditor, LexicalEditor} from 'lexical';
 import {createEmptyHistoryState, registerHistory} from '@lexical/history';
 import {registerRichText} from '@lexical/rich-text';
 import {mergeRegister} from '@lexical/utils';
-import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
+import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
 import {buildEditorUI} from "./ui";
-import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
+import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
 import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
 import {EditorUiContext} from "./ui/framework/core";
 import {listen as listenToCommonEvents} from "./services/common-events";
 import {registerDropPasteHandling} from "./services/drop-paste-handling";
 import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
 import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
-import {el} from "./utils/dom";
 import {registerShortcuts} from "./services/shortcuts";
 import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
 import {registerKeyboardHandling} from "./services/keyboard-handling";
 import {registerAutoLinks} from "./services/auto-links";
+import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
+import {modals} from "./ui/defaults/modals";
+import {CodeBlockDecorator} from "./ui/decorators/code-block";
+import {DiagramDecorator} from "./ui/decorators/diagram";
+
+const theme = {
+    text: {
+        bold: 'editor-theme-bold',
+        code: 'editor-theme-code',
+        italic: 'editor-theme-italic',
+        strikethrough: 'editor-theme-strikethrough',
+        subscript: 'editor-theme-subscript',
+        superscript: 'editor-theme-superscript',
+        underline: 'editor-theme-underline',
+        underlineStrikethrough: 'editor-theme-underline-strikethrough',
+    }
+};
 
 export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
-    const config: CreateEditorArgs = {
+    const editor = createEditor({
         namespace: 'BookStackPageEditor',
         nodes: getNodesForPageEditor(),
         onError: console.error,
-        theme: {
-            text: {
-                bold: 'editor-theme-bold',
-                code: 'editor-theme-code',
-                italic: 'editor-theme-italic',
-                strikethrough: 'editor-theme-strikethrough',
-                subscript: 'editor-theme-subscript',
-                superscript: 'editor-theme-superscript',
-                underline: 'editor-theme-underline',
-                underlineStrikethrough: 'editor-theme-underline-strikethrough',
-            }
-        }
-    };
-
-    const editArea = el('div', {
-        contenteditable: 'true',
-        class: 'editor-content-area page-content',
+        theme: theme,
     });
-    const editWrap = el('div', {
-        class: 'editor-content-wrap',
-    }, [editArea]);
-
-    container.append(editWrap);
-    container.classList.add('editor-container');
-    container.setAttribute('dir', options.textDirection);
-    if (options.darkMode) {
-        container.classList.add('editor-dark');
-    }
-
-    const editor = createEditor(config);
-    editor.setRootElement(editArea);
-    const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options);
+    const context: EditorUiContext = buildEditorUI(container, editor, {
+        ...options,
+        editorClass: 'page-content',
+    });
+    editor.setRootElement(context.editorDOM);
 
     mergeRegister(
         registerRichText(editor),
         registerHistory(editor, createEmptyHistoryState(), 300),
         registerShortcuts(context),
         registerKeyboardHandling(context),
-        registerTableResizer(editor, editWrap),
+        registerTableResizer(editor, context.scrollDOM),
         registerTableSelectionHandler(editor),
-        registerTaskListHandler(editor, editArea),
+        registerTaskListHandler(editor, context.editorDOM),
         registerDropPasteHandling(context),
         registerNodeResizer(context),
         registerAutoLinks(editor),
     );
 
-    listenToCommonEvents(editor);
+    // Register toolbars, modals & decorators
+    context.manager.setToolbar(getMainEditorFullToolbar(context));
+    for (const key of Object.keys(contextToolbars)) {
+        context.manager.registerContextToolbar(key, contextToolbars[key]);
+    }
+    for (const key of Object.keys(modals)) {
+        context.manager.registerModal(key, modals[key]);
+    }
+    context.manager.registerDecoratorType('code', CodeBlockDecorator);
+    context.manager.registerDecoratorType('diagram', DiagramDecorator);
 
+    listenToCommonEvents(editor);
     setEditorContentFromHtml(editor, htmlContent);
 
     const debugView = document.getElementById('lexical-debug');
@@ -89,17 +90,76 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
 
     registerCommonNodeMutationListeners(context);
 
-    return new SimpleWysiwygEditorInterface(editor);
+    return new SimpleWysiwygEditorInterface(context);
+}
+
+export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
+    const editor = createEditor({
+        namespace: 'BookStackBasicEditor',
+        nodes: getNodesForBasicEditor(),
+        onError: console.error,
+        theme: theme,
+    });
+    const context: EditorUiContext = buildEditorUI(container, editor, options);
+    editor.setRootElement(context.editorDOM);
+
+    const editorTeardown = mergeRegister(
+        registerRichText(editor),
+        registerHistory(editor, createEmptyHistoryState(), 300),
+        registerShortcuts(context),
+        registerAutoLinks(editor),
+    );
+
+    // Register toolbars, modals & decorators
+    context.manager.setToolbar(getBasicEditorToolbar(context));
+    context.manager.registerContextToolbar('link', contextToolbars.link);
+    context.manager.registerModal('link', modals.link);
+    context.manager.onTeardown(editorTeardown);
+
+    setEditorContentFromHtml(editor, htmlContent);
+
+    return new SimpleWysiwygEditorInterface(context);
 }
 
 export class SimpleWysiwygEditorInterface {
-    protected editor: LexicalEditor;
+    protected context: EditorUiContext;
+    protected onChangeListeners: (() => void)[] = [];
+    protected editorListenerTeardown: (() => void)|null = null;
 
-    constructor(editor: LexicalEditor) {
-        this.editor = editor;
+    constructor(context: EditorUiContext) {
+        this.context = context;
     }
 
     async getContentAsHtml(): Promise<string> {
-        return await getEditorContentAsHtml(this.editor);
+        return await getEditorContentAsHtml(this.context.editor);
+    }
+
+    onChange(listener: () => void) {
+        this.onChangeListeners.push(listener);
+        this.startListeningToChanges();
+    }
+
+    focus(): void {
+        focusEditor(this.context.editor);
+    }
+
+    remove() {
+        this.context.manager.teardown();
+        this.context.containerDOM.remove();
+        if (this.editorListenerTeardown) {
+            this.editorListenerTeardown();
+        }
+    }
+
+    protected startListeningToChanges(): void {
+        if (this.editorListenerTeardown) {
+            return;
+        }
+
+        this.editorListenerTeardown = this.context.editor.registerUpdateListener(() => {
+             for (const listener of this.onChangeListeners) {
+                 listener();
+             }
+        });
     }
 }
\ No newline at end of file
index c1db0f0869fc3b59b110652598b4e8158c692d1c..413e2c4cd3f7cef0d97be5022ecee44208aa9c2d 100644 (file)
@@ -20,9 +20,6 @@ import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
 import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
 
-/**
- * Load the nodes for lexical.
- */
 export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
     return [
         CalloutNode,
@@ -45,6 +42,15 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
     ];
 }
 
+export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
+    return [
+        ListNode,
+        ListItemNode,
+        ParagraphNode,
+        LinkNode,
+    ];
+}
+
 export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
     const decorated = [ImageNode, CodeBlockNode, DiagramNode];
 
@@ -53,7 +59,7 @@ export function registerCommonNodeMutationListeners(context: EditorUiContext): v
             if (mutation === "destroyed") {
                 const decorator = context.manager.getDecoratorByNodeKey(nodeKey);
                 if (decorator) {
-                    decorator.destroy(context);
+                    decorator.teardown();
                 }
             }
         }
index cdc451d088bc08c02a003eac320773b868996f6b..33468e0a23a5de953da6593b4c99d524e9af9328 100644 (file)
@@ -79,6 +79,7 @@ import {
 import {el} from "../../utils/dom";
 import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu";
 import {EditorSeparator} from "../framework/blocks/separator";
+import {EditorContextToolbarDefinition} from "../framework/toolbars";
 
 export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement {
 
@@ -220,50 +221,74 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
     ]);
 }
 
-export function getImageToolbarContent(): EditorUiElement[] {
-    return [new EditorButton(image)];
-}
-
-export function getMediaToolbarContent(): EditorUiElement[] {
-    return [new EditorButton(media)];
-}
-
-export function getLinkToolbarContent(): EditorUiElement[] {
-    return [
+export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
+    return new EditorSimpleClassContainer('editor-toolbar-main', [
+        new EditorButton(bold),
+        new EditorButton(italic),
         new EditorButton(link),
-        new EditorButton(unlink),
-    ];
-}
-
-export function getCodeToolbarContent(): EditorUiElement[] {
-    return [
-        new EditorButton(editCodeBlock),
-    ];
-}
-
-export function getTableToolbarContent(): EditorUiElement[] {
-    return [
-        new EditorOverflowContainer(2, [
-            new EditorButton(tableProperties),
-            new EditorButton(deleteTable),
-        ]),
-        new EditorOverflowContainer(3, [
-            new EditorButton(insertRowAbove),
-            new EditorButton(insertRowBelow),
-            new EditorButton(deleteRow),
-        ]),
-        new EditorOverflowContainer(3, [
-            new EditorButton(insertColumnBefore),
-            new EditorButton(insertColumnAfter),
-            new EditorButton(deleteColumn),
-        ]),
-    ];
+        new EditorButton(bulletList),
+        new EditorButton(numberList),
+    ]);
 }
 
-export function getDetailsToolbarContent(): EditorUiElement[] {
-    return [
-        new EditorButton(detailsEditLabel),
-        new EditorButton(detailsToggle),
-        new EditorButton(detailsUnwrap),
-    ];
-}
\ No newline at end of file
+export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
+    image: {
+        selector: 'img:not([drawio-diagram] img)',
+        content: () => [new EditorButton(image)],
+    },
+    media: {
+        selector: '.editor-media-wrap',
+        content: () => [new EditorButton(media)],
+    },
+    link: {
+        selector: 'a',
+        content() {
+            return [
+                new EditorButton(link),
+                new EditorButton(unlink),
+            ]
+        },
+        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
+            const image = originalTarget.querySelector('img');
+            return image || originalTarget;
+        }
+    },
+    code: {
+        selector: '.editor-code-block-wrap',
+        content: () => [new EditorButton(editCodeBlock)],
+    },
+    table: {
+        selector: 'td,th',
+        content() {
+            return [
+                new EditorOverflowContainer(2, [
+                    new EditorButton(tableProperties),
+                    new EditorButton(deleteTable),
+                ]),
+                new EditorOverflowContainer(3, [
+                    new EditorButton(insertRowAbove),
+                    new EditorButton(insertRowBelow),
+                    new EditorButton(deleteRow),
+                ]),
+                new EditorOverflowContainer(3, [
+                    new EditorButton(insertColumnBefore),
+                    new EditorButton(insertColumnAfter),
+                    new EditorButton(deleteColumn),
+                ]),
+            ];
+        },
+        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
+            return originalTarget.closest('table') as HTMLTableElement;
+        }
+    },
+    details: {
+        selector: 'details',
+        content() {
+            return [
+                new EditorButton(detailsEditLabel),
+                new EditorButton(detailsToggle),
+                new EditorButton(detailsUnwrap),
+            ]
+        },
+    },
+};
\ No newline at end of file
index ca2ba40c6fc369b5a39e4e6e9c7f1061611b96f9..9c524dff057bceaa9a0a8d4a3dca10f4c79b6a81 100644 (file)
@@ -30,6 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef
 export abstract class EditorUiElement {
     protected dom: HTMLElement|null = null;
     private context: EditorUiContext|null = null;
+    private abortController: AbortController = new AbortController();
 
     protected abstract buildDOM(): HTMLElement;
 
@@ -79,9 +80,16 @@ export abstract class EditorUiElement {
         if (target) {
             target.addEventListener('editor::' + name, ((event: CustomEvent) => {
                 callback(event.detail);
-            }) as EventListener);
+            }) as EventListener, { signal: this.abortController.signal });
         }
     }
+
+    teardown(): void {
+        if (this.dom && this.dom.isConnected) {
+            this.dom.remove();
+        }
+        this.abortController.abort('teardown');
+    }
 }
 
 export class EditorContainerUiElement extends EditorUiElement {
@@ -129,6 +137,13 @@ export class EditorContainerUiElement extends EditorUiElement {
             child.setContext(context);
         }
     }
+
+    teardown() {
+        for (const child of this.children) {
+            child.teardown();
+        }
+        super.teardown();
+    }
 }
 
 export class EditorSimpleClassContainer extends EditorContainerUiElement {
index 570b8222b9c8e1fed5cb8b08db28382d859d9fa8..6ea0b8b393426f2840d68a2d4d6885c889d2d8cc 100644 (file)
@@ -48,7 +48,7 @@ export abstract class EditorDecorator {
      * Destroy this decorator. Used for tear-down operations upon destruction
      * of the underlying node this decorator is attached to.
      */
-    destroy(context: EditorUiContext): void {
+    teardown(): void {
         for (const callback of this.onDestroyCallbacks) {
             callback();
         }
index 751c1b3f207233134bcb8c0a3a70009d054fa8a4..890d5b325fe97b0a18f833d90eacddfefd188d2b 100644 (file)
@@ -41,11 +41,18 @@ export class DropDownManager {
 
     constructor() {
         this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
+        this.onWindowClick = this.onWindowClick.bind(this);
 
-        window.addEventListener('click', (event: MouseEvent) => {
-            const target = event.target as HTMLElement;
-            this.closeAllNotContainingElement(target);
-        });
+        window.addEventListener('click', this.onWindowClick);
+    }
+
+    teardown(): void {
+        window.removeEventListener('click', this.onWindowClick);
+    }
+
+    protected onWindowClick(event: MouseEvent): void {
+        const target = event.target as HTMLElement;
+        this.closeAllNotContainingElement(target);
     }
 
     protected closeAllNotContainingElement(element: HTMLElement): void {
index 2d15b341bdba6ae9a39e9236c77e5a6742854c4f..3f46455da630e7618d2c5e63298d8f5508459b6b 100644 (file)
@@ -12,6 +12,8 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
 
 export class EditorUIManager {
 
+    public dropdowns: DropDownManager = new DropDownManager();
+
     protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
     protected activeModalsByKey: Record<string, EditorFormModal> = {};
     protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
@@ -21,12 +23,12 @@ export class EditorUIManager {
     protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
     protected activeContextToolbars: EditorContextToolbar[] = [];
     protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
-
-    public dropdowns: DropDownManager = new DropDownManager();
+    protected domEventAbortController = new AbortController();
+    protected teardownCallbacks: (()=>void)[] = [];
 
     setContext(context: EditorUiContext) {
         this.context = context;
-        this.setupEventListeners(context);
+        this.setupEventListeners();
         this.setupEditor(context.editor);
     }
 
@@ -99,7 +101,7 @@ export class EditorUIManager {
 
     setToolbar(toolbar: EditorContainerUiElement) {
         if (this.toolbar) {
-            this.toolbar.getDOMElement().remove();
+            this.toolbar.teardown();
         }
 
         this.toolbar = toolbar;
@@ -170,10 +172,40 @@ export class EditorUIManager {
         return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
     }
 
+    onTeardown(callback: () => void): void {
+        this.teardownCallbacks.push(callback);
+    }
+
+    teardown(): void {
+        this.domEventAbortController.abort('teardown');
+
+        for (const [_, modal] of Object.entries(this.activeModalsByKey)) {
+            modal.teardown();
+        }
+
+        for (const [_, decorator] of Object.entries(this.decoratorInstancesByNodeKey)) {
+            decorator.teardown();
+        }
+
+        if (this.toolbar) {
+            this.toolbar.teardown();
+        }
+
+        for (const toolbar of this.activeContextToolbars) {
+            toolbar.teardown();
+        }
+
+        this.dropdowns.teardown();
+
+        for (const callback of this.teardownCallbacks) {
+            callback();
+        }
+    }
+
     protected updateContextToolbars(update: EditorUiStateUpdate): void {
         for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
             const toolbar = this.activeContextToolbars[i];
-            toolbar.destroy();
+            toolbar.teardown();
             this.activeContextToolbars.splice(i, 1);
         }
 
@@ -198,7 +230,7 @@ export class EditorUIManager {
                     contentByTarget.set(targetEl, [])
                 }
                 // @ts-ignore
-                contentByTarget.get(targetEl).push(...definition.content);
+                contentByTarget.get(targetEl).push(...definition.content());
             }
         }
 
@@ -253,9 +285,9 @@ export class EditorUIManager {
         });
     }
 
-    protected setupEventListeners(context: EditorUiContext) {
+    protected setupEventListeners() {
         const layoutUpdate = this.triggerLayoutUpdate.bind(this);
-        window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
-        window.addEventListener('resize', layoutUpdate, {passive: true});
+        window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
+        window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});
     }
 }
\ No newline at end of file
index 3eea62ebb94d9cca2e1ab1ae9bfff7f1990467e0..4dbe9d962c5849611c2840b9ec9306efd1dda712 100644 (file)
@@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement {
     }
 
     hide() {
-        this.getDOMElement().remove();
         this.getContext().manager.setModalInactive(this.key);
+        this.teardown();
     }
 
     getForm(): EditorForm {
index de2255444ebf6fcfc43fb7a9a225c3f36c208cc1..cf5ec4ad15118aba17073e76b935e7d9c52578ec 100644 (file)
@@ -4,7 +4,7 @@ import {el} from "../../utils/dom";
 
 export type EditorContextToolbarDefinition = {
     selector: string;
-    content: EditorUiElement[],
+    content: () => EditorUiElement[],
     displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement;
 };
 
@@ -60,17 +60,4 @@ export class EditorContextToolbar extends EditorContainerUiElement {
         const dom = this.getDOMElement();
         dom.append(...children.map(child => child.getDOMElement()));
     }
-
-    protected empty() {
-        const children = this.getChildren();
-        for (const child of children) {
-            child.getDOMElement().remove();
-        }
-        this.removeChildren(...children);
-    }
-
-    destroy() {
-        this.empty();
-        this.getDOMElement().remove();
-    }
 }
\ No newline at end of file
index e7ec6adbcd56b7eff8e6676b89220707ad329617..c48386bb4920e196eba48be07eca100bc713ae0f 100644 (file)
@@ -1,23 +1,30 @@
 import {LexicalEditor} from "lexical";
-import {
-    getCodeToolbarContent, getDetailsToolbarContent,
-    getImageToolbarContent,
-    getLinkToolbarContent,
-    getMainEditorFullToolbar, getMediaToolbarContent, getTableToolbarContent
-} from "./defaults/toolbars";
 import {EditorUIManager} from "./framework/manager";
 import {EditorUiContext} from "./framework/core";
-import {CodeBlockDecorator} from "./decorators/code-block";
-import {DiagramDecorator} from "./decorators/diagram";
-import {modals} from "./defaults/modals";
+import {el} from "../utils/dom";
+
+export function buildEditorUI(containerDOM: HTMLElement, editor: LexicalEditor, options: Record<string, any>): EditorUiContext {
+    const editorDOM = el('div', {
+        contenteditable: 'true',
+        class: `editor-content-area ${options.editorClass || ''}`,
+    });
+    const scrollDOM = el('div', {
+        class: 'editor-content-wrap',
+    }, [editorDOM]);
+
+    containerDOM.append(scrollDOM);
+    containerDOM.classList.add('editor-container');
+    containerDOM.setAttribute('dir', options.textDirection);
+    if (options.darkMode) {
+        containerDOM.classList.add('editor-dark');
+    }
 
-export function buildEditorUI(container: HTMLElement, element: HTMLElement, scrollContainer: HTMLElement, editor: LexicalEditor, options: Record<string, any>): EditorUiContext {
     const manager = new EditorUIManager();
     const context: EditorUiContext = {
         editor,
-        containerDOM: container,
-        editorDOM: element,
-        scrollDOM: scrollContainer,
+        containerDOM: containerDOM,
+        editorDOM: editorDOM,
+        scrollDOM: scrollDOM,
         manager,
         translate(text: string): string {
             const translations = options.translations;
@@ -31,50 +38,5 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
     };
     manager.setContext(context);
 
-    // Create primary toolbar
-    manager.setToolbar(getMainEditorFullToolbar(context));
-
-    // Register modals
-    for (const key of Object.keys(modals)) {
-        manager.registerModal(key, modals[key]);
-    }
-
-    // Register context toolbars
-    manager.registerContextToolbar('image', {
-        selector: 'img:not([drawio-diagram] img)',
-        content: getImageToolbarContent(),
-    });
-    manager.registerContextToolbar('media', {
-        selector: '.editor-media-wrap',
-        content: getMediaToolbarContent(),
-    });
-    manager.registerContextToolbar('link', {
-        selector: 'a',
-        content: getLinkToolbarContent(),
-        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
-            const image = originalTarget.querySelector('img');
-            return image || originalTarget;
-        }
-    });
-    manager.registerContextToolbar('code', {
-        selector: '.editor-code-block-wrap',
-        content: getCodeToolbarContent(),
-    });
-    manager.registerContextToolbar('table', {
-        selector: 'td,th',
-        content: getTableToolbarContent(),
-        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
-            return originalTarget.closest('table') as HTMLTableElement;
-        }
-    });
-    manager.registerContextToolbar('details', {
-        selector: 'details',
-        content: getDetailsToolbarContent(),
-    });
-
-    // Register image decorator listener
-    manager.registerDecoratorType('code', CodeBlockDecorator);
-    manager.registerDecoratorType('diagram', DiagramDecorator);
-
     return context;
 }
\ No newline at end of file
index ae829bae3a3c7cfbbb28fc88138b58a87facbd8f..b7ce65eeb6c52edfed1ed056d4da199d435f17ea 100644 (file)
@@ -64,6 +64,6 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise<string> {
     });
 }
 
-export function focusEditor(editor: LexicalEditor) {
+export function focusEditor(editor: LexicalEditor): void {
     editor.focus(() => {}, {defaultSelection: "rootStart"});
 }
\ No newline at end of file
index 633fa78a6b19e0050bb00ea6183c07e14292cc9e..de43540a31d80b5a9d5cc3ee2ba1f99db270c227 100644 (file)
@@ -52,6 +52,25 @@ body.editor-is-fullscreen {
   flex: 1;
 }
 
+// Variation specific styles
+.comment-editor-container,
+.basic-editor-container {
+  border-left: 1px solid #DDD;
+  border-right: 1px solid #DDD;
+  border-bottom: 1px solid #DDD;
+  border-radius: 3px;
+  @include mixins.lightDark(border-color, #DDD, #000);
+
+  .editor-toolbar-main {
+    border-radius: 3px 3px 0 0;
+    justify-content: end;
+  }
+}
+
+.basic-editor-container .editor-content-area {
+  padding-bottom: 0;
+}
+
 // Buttons
 .editor-button {
   font-size: 12px;
index 8175db948a5c6617522d57c980a26706503b00d8..48b4b0ca22ef5d28430140049b817c6fbba2e217 100644 (file)
@@ -389,10 +389,12 @@ body.flexbox {
 .tri-layout-right {
   grid-area: c;
   min-width: 0;
+  position: relative;
 }
 .tri-layout-left {
   grid-area: a;
   min-width: 0;
+  position: relative;
 }
 
 @include mixins.larger-than(vars.$bp-xxl) {
@@ -431,7 +433,8 @@ body.flexbox {
     grid-template-areas:  "a b b";
     grid-template-columns: 1fr 3fr;
     grid-template-rows: min-content min-content 1fr;
-    padding-inline-end: vars.$l;
+    margin-inline-start: (vars.$m + vars.$xxs);
+    margin-inline-end: (vars.$m + vars.$xxs);
   }
   .tri-layout-sides {
     grid-column-start: a;
@@ -452,6 +455,8 @@ body.flexbox {
     height: 100%;
     scrollbar-width: none;
     -ms-overflow-style: none;
+    padding-inline: vars.$m;
+    margin-inline: -(vars.$m);
     &::-webkit-scrollbar {
       display: none;
     }
@@ -520,4 +525,26 @@ body.flexbox {
     margin-inline-start: 0;
     margin-inline-end: 0;
   }
+}
+
+/**
+ * Scroll Indicators
+ */
+.scroll-away-from-top:before,
+.scroll-away-from-bottom:after {
+  content: '';
+  display: block;
+  position: absolute;
+  @include mixins.lightDark(color, #F2F2F2, #111);
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 50px;
+  background: linear-gradient(to bottom, currentColor, transparent);
+  z-index: 2;
+}
+.scroll-away-from-bottom:after {
+  top: auto;
+  bottom: 0;
+  background: linear-gradient(to top, currentColor, transparent);
 }
\ No newline at end of file
index ee261e72d4a55b5449f84891cafcec85502711c2..44d495c27770afe3b512222706f02c02ae5cf8fc 100644 (file)
@@ -1,7 +1,3 @@
-@push('head')
-    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
-@endpush
-
 {{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
index 602693916ea04ef2b00c9779b7a93ea0c0a28e61..70721631d43dbeefe5fbd613086f0bad6d650f6d 100644 (file)
@@ -1,7 +1,3 @@
-@push('head')
-    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
-@endpush
-
 {{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
index eadf3518722c0a6ff29ba90f8cb711cd1a6f903b..d70a8c1d909b1d1b55f88347060b1049186ad46b 100644 (file)
@@ -7,7 +7,6 @@
      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}}"
      class="comment-box">
index f27127e9732c1970d4ff0d977ac3e3c91198a9da..a5f0168a5c8a95a5f4db3b695992703712928e91 100644 (file)
@@ -3,7 +3,6 @@
          option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
          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 tab-container"
          aria-label="{{ trans('entities.comments') }}">
@@ -73,7 +72,6 @@
 
     @if(userCan('comment-create-all') || $commentTree->canUpdateAny())
         @push('body-end')
-            <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>
             @include('form.editor-translations')
             @include('entities.selector-popup')
         @endpush
index 08427f1a5ed293e7c9eae924ea3bdbcfdadf44d0..f9ba023c37ee464f71e9c7ec551c7548cf2b8118 100644 (file)
@@ -1 +1 @@
-@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity->tags->all()))->generateAsString() . ' '))
\ No newline at end of file
+@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity))->generateAsString() . ' '))
\ No newline at end of file
index 3cf726ba48932a2ff88ed144418c4b2742bbf474..52244eda6f9f4cd2369cdab3ce295108532066e7 100644 (file)
@@ -1,5 +1,4 @@
 <textarea component="wysiwyg-input"
-          option:wysiwyg-input:language="{{ $locale->htmlLang() }}"
           option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}"
           id="description_html" name="description_html" rows="5"
           @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionHtml()}}@endif</textarea>
index c3cedf0fbc2a2106c91f954b3a8142cb16db53c2..061cc69945c3dcace59ea8bed5401a21f3ed3773 100644 (file)
     <div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') >
 
         <div class="tri-layout-sides print-hidden">
-            <div class="tri-layout-sides-content">
+            <div refs="tri-layout@sidebar-scroll-container" class="tri-layout-sides-content">
                 <div class="tri-layout-right print-hidden">
-                    <aside class="tri-layout-right-contents">
+                    <aside refs="tri-layout@sidebar-scroll-container" class="tri-layout-right-contents">
                         @yield('right')
                     </aside>
                 </div>
 
                 <div class="tri-layout-left print-hidden" id="sidebar">
-                    <aside class="tri-layout-left-contents">
+                    <aside refs="tri-layout@sidebar-scroll-container" class="tri-layout-left-contents">
                         @yield('left')
                     </aside>
                 </div>
index 7790ba5a4e7fab2e994999a8e17fbfaaf3c8e8f5..0207d72780be1aeb32e39d899bdb4244477fcb88 100644 (file)
@@ -1,7 +1,3 @@
-@push('head')
-    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
-@endpush
-
 {{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
index 22e96c250b9f6623d2ad13a76fbe8393e2bd2129..bffe29fa95bb59c087ff4904ce023d4233994857 100644 (file)
@@ -60,7 +60,6 @@ class CommentDisplayTest extends TestCase
         $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);
 
@@ -68,7 +67,6 @@ class CommentDisplayTest extends TestCase
         $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);
 
@@ -79,7 +77,6 @@ class CommentDisplayTest extends TestCase
         ]);
 
         $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);
     }
index 8b8a5d488b869447d3a8f905c57324c6dc1c7b6a..c5fe4ce5064a600bbfa0fd427f780468e6482c8c 100644 (file)
@@ -193,13 +193,14 @@ class CommentStoreTest extends TestCase
     {
         $page = $this->entities->page();
 
-        $script = '<script>const a = "script";</script><p onclick="1">My lovely comment</p>';
+        $script = '<script>const a = "script";</script><script>const b = "sneakyscript";</script><p onclick="1">My lovely comment</p>';
         $this->asAdmin()->postJson("/comment/$page->id", [
             'html' => $script,
         ]);
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee($script, false);
+        $pageView->assertDontSee('sneakyscript', false);
         $pageView->assertSee('<p>My lovely comment</p>', false);
 
         $comment = $page->comments()->first();
@@ -209,6 +210,7 @@ class CommentStoreTest extends TestCase
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee($script, false);
+        $pageView->assertDontSee('sneakyscript', false);
         $pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
     }
 
@@ -216,7 +218,7 @@ class CommentStoreTest extends TestCase
     {
         $page = $this->entities->page();
         Comment::factory()->create([
-            'html' => '<script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
+            'html' => '<script>superbadscript</script><script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
             'entity_type' => 'page', 'entity_id' => $page
         ]);
 
@@ -229,7 +231,7 @@ class CommentStoreTest extends TestCase
     public function test_comment_html_is_limited()
     {
         $page = $this->entities->page();
-        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section></p>';
+        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section><section>there</section></p>';
         $expected = '<p>Content<a href="#cat">a</a></p>';
 
         $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
@@ -248,4 +250,27 @@ class CommentStoreTest extends TestCase
             'html' => $expected,
         ]);
     }
+
+    public function test_comment_html_spans_are_cleaned()
+    {
+        $page = $this->entities->page();
+        $input = '<p><span class="beans">Hello</span> do you have <span style="white-space: discard;">biscuits</span>?</p>';
+        $expected = '<p><span>Hello</span> do you have <span>biscuits</span>?</p>';
+
+        $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'entity_type' => 'page',
+            'entity_id' => $page->id,
+            'html' => $expected,
+        ]);
+
+        $comment = $page->comments()->first();
+        $resp = $this->put("/comment/{$comment->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'id'   => $comment->id,
+            'html' => $expected,
+        ]);
+    }
 }
index 729f9390358d5ff1e044a00bed7e67ddb82cf23e..63f037d9cc011ef6783987617ab8de36e133a7f9 100644 (file)
@@ -230,4 +230,39 @@ class TagTest extends TestCase
         $resp->assertDontSee('tag-name-<>', false);
         $resp->assertSee('tag-name-&lt;&gt;', false);
     }
+
+    public function test_parent_tag_classes_visible()
+    {
+        $page = $this->entities->pageWithinChapter();
+        $page->chapter->tags()->create(['name' => 'My Chapter Tag', 'value' => 'abc123']);
+        $page->book->tags()->create(['name' => 'My Book Tag', 'value' => 'def456']);
+        $this->asEditor();
+
+        $html = $this->withHtml($this->get($page->getUrl()));
+        $html->assertElementExists('body.chapter-tag-pair-mychaptertag-abc123');
+        $html->assertElementExists('body.book-tag-pair-mybooktag-def456');
+
+        $html = $this->withHtml($this->get($page->chapter->getUrl()));
+        $html->assertElementExists('body.book-tag-pair-mybooktag-def456');
+    }
+
+    public function test_parent_tag_classes_not_visible_if_cannot_see_parent()
+    {
+        $page = $this->entities->pageWithinChapter();
+        $page->chapter->tags()->create(['name' => 'My Chapter Tag', 'value' => 'abc123']);
+        $page->book->tags()->create(['name' => 'My Book Tag', 'value' => 'def456']);
+        $editor = $this->users->editor();
+        $this->actingAs($editor);
+
+        $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]);
+        $this->permissions->disableEntityInheritedPermissions($page->chapter);
+
+        $html = $this->withHtml($this->get($page->getUrl()));
+        $html->assertElementNotExists('body.chapter-tag-pair-mychaptertag-abc123');
+        $html->assertElementExists('body.book-tag-pair-mybooktag-def456');
+
+        $this->permissions->disableEntityInheritedPermissions($page->book);
+        $html = $this->withHtml($this->get($page->getUrl()));
+        $html->assertElementNotExists('body.book-tag-pair-mybooktag-def456');
+    }
 }