1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-19 22:03:19 +03:00

Merge branch 'development' of github.com:BookStackApp/BookStack into development

This commit is contained in:
Dan Brown
2025-07-14 14:18:51 +01:00
50 changed files with 787 additions and 497 deletions

View File

@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $value * @property string $value
* @property int $entity_id
* @property string $entity_type
* @property int $order * @property int $order
*/ */
class Tag extends Model class Tag extends Model

View File

@ -3,17 +3,15 @@
namespace BookStack\Activity\Tools; namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Tag; use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
class TagClassGenerator class TagClassGenerator
{ {
protected array $tags; public function __construct(
protected Entity $entity
/** ) {
* @param Tag[] $tags
*/
public function __construct(array $tags)
{
$this->tags = $tags;
} }
/** /**
@ -22,14 +20,23 @@ class TagClassGenerator
public function generate(): array public function generate(): array
{ {
$classes = []; $classes = [];
$tags = $this->entity->tags->all();
foreach ($this->tags as $tag) { foreach ($tags as $tag) {
$name = $this->normalizeTagClassString($tag->name); array_push($classes, ...$this->generateClassesForTag($tag));
$value = $this->normalizeTagClassString($tag->value); }
$classes[] = 'tag-name-' . $name;
if ($value) { if ($this->entity instanceof BookChild && userCan('view', $this->entity->book)) {
$classes[] = 'tag-value-' . $value; $bookTags = $this->entity->book->tags;
$classes[] = 'tag-pair-' . $name . '-' . $value; foreach ($bookTags as $bookTag) {
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
}
}
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 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 protected function normalizeTagClassString(string $value): string
{ {
$value = str_replace(' ', '', strtolower($value)); $value = str_replace(' ', '', strtolower($value));

View File

@ -18,6 +18,7 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\References\ReferenceFetcher; use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use BookStack\Util\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -263,7 +264,9 @@ class BookController extends Controller
$this->checkPermission('bookshelf-create-all'); $this->checkPermission('bookshelf-create-all');
$this->checkPermission('book-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()); return redirect($shelf->getUrl());
} }

View File

@ -18,6 +18,7 @@ use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\References\ReferenceFetcher; use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Throwable; use Throwable;
@ -269,7 +270,9 @@ class ChapterController extends Controller
$this->checkOwnablePermission('chapter-delete', $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkPermission('book-create-all'); $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()); return redirect($book->getUrl());
} }

View File

@ -77,7 +77,6 @@ class BaseRepo
$entity->touch(); $entity->touch();
} }
$entity->rebuildPermissions();
$entity->indexForSearch(); $entity->indexForSearch();
$this->referenceStore->updateForEntity($entity); $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. * 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 public function sortParent(Entity $entity): void
{ {

View File

@ -10,6 +10,7 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Sorting\SortRule; use BookStack\Sorting\SortRule;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use BookStack\Util\DatabaseTransaction;
use Exception; use Exception;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -28,7 +29,9 @@ class BookRepo
*/ */
public function create(array $input): Book public function create(array $input): Book
{ {
return (new DatabaseTransaction(function () use ($input) {
$book = new Book(); $book = new Book();
$this->baseRepo->create($book, $input); $this->baseRepo->create($book, $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null); $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
@ -41,6 +44,7 @@ class BookRepo
} }
return $book; return $book;
}))->run();
} }
/** /**

View File

@ -7,6 +7,7 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\TrashCan; use BookStack\Entities\Tools\TrashCan;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Util\DatabaseTransaction;
use Exception; use Exception;
class BookshelfRepo class BookshelfRepo
@ -23,13 +24,14 @@ class BookshelfRepo
*/ */
public function create(array $input, array $bookIds): Bookshelf public function create(array $input, array $bookIds): Bookshelf
{ {
return (new DatabaseTransaction(function () use ($input, $bookIds) {
$shelf = new Bookshelf(); $shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input); $this->baseRepo->create($shelf, $input);
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
$this->updateBooks($shelf, $bookIds); $this->updateBooks($shelf, $bookIds);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
return $shelf; return $shelf;
}))->run();
} }
/** /**

View File

@ -11,6 +11,7 @@ use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Util\DatabaseTransaction;
use Exception; use Exception;
class ChapterRepo class ChapterRepo
@ -27,6 +28,7 @@ class ChapterRepo
*/ */
public function create(array $input, Book $parentBook): Chapter public function create(array $input, Book $parentBook): Chapter
{ {
return (new DatabaseTransaction(function () use ($input, $parentBook) {
$chapter = new Chapter(); $chapter = new Chapter();
$chapter->book_id = $parentBook->id; $chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
@ -37,6 +39,7 @@ class ChapterRepo
$this->baseRepo->sortParent($chapter); $this->baseRepo->sortParent($chapter);
return $chapter; return $chapter;
}))->run();
} }
/** /**
@ -88,6 +91,7 @@ class ChapterRepo
throw new PermissionsException('User does not have permission to create a chapter within the chosen book'); throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
} }
return (new DatabaseTransaction(function () use ($chapter, $parent) {
$chapter->changeBook($parent->id); $chapter->changeBook($parent->id);
$chapter->rebuildPermissions(); $chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter); Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
@ -95,5 +99,6 @@ class ChapterRepo
$this->baseRepo->sortParent($chapter); $this->baseRepo->sortParent($chapter);
return $parent; return $parent;
}))->run();
} }
} }

View File

@ -18,6 +18,7 @@ use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\References\ReferenceStore; use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater; use BookStack\References\ReferenceUpdater;
use BookStack\Util\DatabaseTransaction;
use Exception; use Exception;
class PageRepo class PageRepo
@ -61,8 +62,10 @@ class PageRepo
]); ]);
} }
(new DatabaseTransaction(function () use ($page) {
$page->save(); $page->save();
$page->refresh()->rebuildPermissions(); $page->refresh()->rebuildPermissions();
}))->run();
return $page; return $page;
} }
@ -72,11 +75,13 @@ class PageRepo
*/ */
public function publishDraft(Page $draft, array $input): Page public function publishDraft(Page $draft, array $input): Page
{ {
return (new DatabaseTransaction(function () use ($draft, $input) {
$draft->draft = false; $draft->draft = false;
$draft->revision_count = 1; $draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft); $draft->priority = $this->getNewPriority($draft);
$this->updateTemplateStatusAndContentFromInput($draft, $input); $this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input); $this->baseRepo->update($draft, $input);
$draft->rebuildPermissions();
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
$this->revisionRepo->storeNewForPage($draft, $summary); $this->revisionRepo->storeNewForPage($draft, $summary);
@ -86,12 +91,13 @@ class PageRepo
$this->baseRepo->sortParent($draft); $this->baseRepo->sortParent($draft);
return $draft; return $draft;
}))->run();
} }
/** /**
* Directly update the content for the given page from the provided input. * Directly update the content for the given page from the provided input.
* Used for direct content access in a way that performs required changes * 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 public function setContentFromInput(Page $page, array $input): void
{ {
@ -116,7 +122,7 @@ class PageRepo
$page->revision_count++; $page->revision_count++;
$page->save(); $page->save();
// Remove all update drafts for this user & page. // Remove all update drafts for this user and page.
$this->revisionRepo->deleteDraftsForCurrentUser($page); $this->revisionRepo->deleteDraftsForCurrentUser($page);
// Save a revision after updating // Save a revision after updating
@ -269,6 +275,7 @@ class PageRepo
throw new PermissionsException('User does not have permission to create a page within the new parent'); throw new PermissionsException('User does not have permission to create a page within the new parent');
} }
return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page->changeBook($newBookId); $page->changeBook($newBookId);
@ -279,6 +286,7 @@ class PageRepo
$this->baseRepo->sortParent($page); $this->baseRepo->sortParent($page);
return $parent; return $parent;
}))->run();
} }
/** /**

View File

@ -13,17 +13,12 @@ use BookStack\Facades\Activity;
class HierarchyTransformer class HierarchyTransformer
{ {
protected BookRepo $bookRepo; public function __construct(
protected BookshelfRepo $shelfRepo; protected BookRepo $bookRepo,
protected Cloner $cloner; protected BookshelfRepo $shelfRepo,
protected TrashCan $trashCan; 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;
} }
/** /**

View File

@ -15,6 +15,7 @@ use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService; use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use BookStack\Util\DatabaseTransaction;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -357,25 +358,26 @@ class TrashCan
/** /**
* Destroy the given entity. * Destroy the given entity.
* Returns the number of total entities destroyed in the operation.
* *
* @throws Exception * @throws Exception
*/ */
public function destroyEntity(Entity $entity): int public function destroyEntity(Entity $entity): int
{ {
$result = (new DatabaseTransaction(function () use ($entity) {
if ($entity instanceof Page) { if ($entity instanceof Page) {
return $this->destroyPage($entity); return $this->destroyPage($entity);
} } else if ($entity instanceof Chapter) {
if ($entity instanceof Chapter) {
return $this->destroyChapter($entity); return $this->destroyChapter($entity);
} } else if ($entity instanceof Book) {
if ($entity instanceof Book) {
return $this->destroyBook($entity); return $this->destroyBook($entity);
} } else if ($entity instanceof Bookshelf) {
if ($entity instanceof Bookshelf) {
return $this->destroyShelf($entity); return $this->destroyShelf($entity);
} }
return null;
}))->run();
return 0; return $result ?? 0;
} }
/** /**

View File

@ -29,7 +29,7 @@ class JointPermissionBuilder
/** /**
* Re-generate all entity permission from scratch. * Re-generate all entity permission from scratch.
*/ */
public function rebuildForAll() public function rebuildForAll(): void
{ {
JointPermission::query()->truncate(); JointPermission::query()->truncate();
@ -51,7 +51,7 @@ class JointPermissionBuilder
/** /**
* Rebuild the entity jointPermissions for a particular entity. * Rebuild the entity jointPermissions for a particular entity.
*/ */
public function rebuildForEntity(Entity $entity) public function rebuildForEntity(Entity $entity): void
{ {
$entities = [$entity]; $entities = [$entity];
if ($entity instanceof Book) { if ($entity instanceof Book) {
@ -119,7 +119,7 @@ class JointPermissionBuilder
/** /**
* Build joint permissions for the given book and role combinations. * 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; $entities = clone $books;
@ -143,7 +143,7 @@ class JointPermissionBuilder
/** /**
* Rebuild the entity jointPermissions for a collection of entities. * 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(); $roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities); $this->deleteManyJointPermissionsForEntities($entities);
@ -155,12 +155,11 @@ class JointPermissionBuilder
* *
* @param Entity[] $entities * @param Entity[] $entities
*/ */
protected function deleteManyJointPermissionsForEntities(array $entities) protected function deleteManyJointPermissionsForEntities(array $entities): void
{ {
$simpleEntities = $this->entitiesToSimpleEntities($entities); $simpleEntities = $this->entitiesToSimpleEntities($entities);
$idsByType = $this->entitiesToTypeIdMap($simpleEntities); $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
DB::transaction(function () use ($idsByType) {
foreach ($idsByType as $type => $ids) { foreach ($idsByType as $type => $ids) {
foreach (array_chunk($ids, 1000) as $idChunk) { foreach (array_chunk($ids, 1000) as $idChunk) {
DB::table('joint_permissions') DB::table('joint_permissions')
@ -169,7 +168,6 @@ class JointPermissionBuilder
->delete(); ->delete();
} }
} }
});
} }
/** /**
@ -195,7 +193,7 @@ class JointPermissionBuilder
* @param Entity[] $originalEntities * @param Entity[] $originalEntities
* @param Role[] $roles * @param Role[] $roles
*/ */
protected function createManyJointPermissions(array $originalEntities, array $roles) protected function createManyJointPermissions(array $originalEntities, array $roles): void
{ {
$entities = $this->entitiesToSimpleEntities($originalEntities); $entities = $this->entitiesToSimpleEntities($originalEntities);
$jointPermissions = []; $jointPermissions = [];
@ -225,11 +223,9 @@ class JointPermissionBuilder
} }
} }
DB::transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) { foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
DB::table('joint_permissions')->insert($jointPermissionChunk); DB::table('joint_permissions')->insert($jointPermissionChunk);
} }
});
} }
/** /**

View File

@ -7,6 +7,7 @@ use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\EntityPermission;
use BookStack\Users\Models\Role; use BookStack\Users\Models\Role;
use BookStack\Util\DatabaseTransaction;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class PermissionsController extends Controller class PermissionsController extends Controller
@ -40,7 +41,9 @@ class PermissionsController extends Controller
$page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('restrictions-manage', $page); $this->checkOwnablePermission('restrictions-manage', $page);
(new DatabaseTransaction(function () use ($page, $request) {
$this->permissionsUpdater->updateFromPermissionsForm($page, $request); $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
}))->run();
$this->showSuccessNotification(trans('entities.pages_permissions_success')); $this->showSuccessNotification(trans('entities.pages_permissions_success'));
@ -70,7 +73,9 @@ class PermissionsController extends Controller
$chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('restrictions-manage', $chapter); $this->checkOwnablePermission('restrictions-manage', $chapter);
(new DatabaseTransaction(function () use ($chapter, $request) {
$this->permissionsUpdater->updateFromPermissionsForm($chapter, $request); $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
}))->run();
$this->showSuccessNotification(trans('entities.chapters_permissions_success')); $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
@ -100,7 +105,9 @@ class PermissionsController extends Controller
$book = $this->queries->books->findVisibleBySlugOrFail($slug); $book = $this->queries->books->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('restrictions-manage', $book); $this->checkOwnablePermission('restrictions-manage', $book);
(new DatabaseTransaction(function () use ($book, $request) {
$this->permissionsUpdater->updateFromPermissionsForm($book, $request); $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
}))->run();
$this->showSuccessNotification(trans('entities.books_permissions_updated')); $this->showSuccessNotification(trans('entities.books_permissions_updated'));
@ -130,7 +137,9 @@ class PermissionsController extends Controller
$shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug); $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf); $this->checkOwnablePermission('restrictions-manage', $shelf);
(new DatabaseTransaction(function () use ($shelf, $request) {
$this->permissionsUpdater->updateFromPermissionsForm($shelf, $request); $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
}))->run();
$this->showSuccessNotification(trans('entities.shelves_permissions_updated')); $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
@ -145,7 +154,10 @@ class PermissionsController extends Controller
$shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug); $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf); $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])); $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
return redirect($shelf->getUrl()); return redirect($shelf->getUrl());

View File

@ -7,6 +7,7 @@ use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Permissions\Models\RolePermission; use BookStack\Permissions\Models\RolePermission;
use BookStack\Users\Models\Role; use BookStack\Users\Models\Role;
use BookStack\Util\DatabaseTransaction;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -48,6 +49,7 @@ class PermissionsRepo
*/ */
public function saveNewRole(array $roleData): Role public function saveNewRole(array $roleData): Role
{ {
return (new DatabaseTransaction(function () use ($roleData) {
$role = new Role($roleData); $role = new Role($roleData);
$role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false); $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
$role->save(); $role->save();
@ -59,16 +61,18 @@ class PermissionsRepo
Activity::add(ActivityType::ROLE_CREATE, $role); Activity::add(ActivityType::ROLE_CREATE, $role);
return $role; return $role;
}))->run();
} }
/** /**
* Updates an existing role. * 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 public function updateRole($roleId, array $roleData): Role
{ {
$role = $this->getRoleById($roleId); $role = $this->getRoleById($roleId);
return (new DatabaseTransaction(function () use ($role, $roleData) {
if (isset($roleData['permissions'])) { if (isset($roleData['permissions'])) {
$this->assignRolePermissions($role, $roleData['permissions']); $this->assignRolePermissions($role, $roleData['permissions']);
} }
@ -80,6 +84,7 @@ class PermissionsRepo
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. * Delete a role from the system.
* Check it's not an admin role or set as default before deleting. * 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. * will be added to the role of the specified id.
* *
* @throws PermissionsException * @throws PermissionsException
@ -131,6 +136,7 @@ class PermissionsRepo
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete')); throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
} }
(new DatabaseTransaction(function () use ($migrateRoleId, $role) {
if ($migrateRoleId !== 0) { if ($migrateRoleId !== 0) {
$newRole = Role::query()->find($migrateRoleId); $newRole = Role::query()->find($migrateRoleId);
if ($newRole) { if ($newRole) {
@ -143,5 +149,6 @@ class PermissionsRepo
$role->jointPermissions()->delete(); $role->jointPermissions()->delete();
Activity::add(ActivityType::ROLE_DELETE, $role); Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete(); $role->delete();
}))->run();
} }
} }

View File

@ -7,6 +7,7 @@ use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Util\DatabaseTransaction;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class BookSortController extends Controller class BookSortController extends Controller
@ -55,16 +56,18 @@ class BookSortController extends Controller
// Sort via map // Sort via map
if ($request->filled('sort-tree')) { if ($request->filled('sort-tree')) {
(new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) {
$sortMap = BookSortMap::fromJson($request->get('sort-tree')); $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$booksInvolved = $sorter->sortUsingMap($sortMap); $booksInvolved = $sorter->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books. // Add activity for involved books.
foreach ($booksInvolved as $bookInvolved) { foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved); Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
if ($bookInvolved->id === $book->id) { if ($bookInvolved->id === $book->id) {
$loggedActivityForBook = true; $loggedActivityForBook = true;
} }
} }
}))->run();
} }
if ($request->filled('auto-sort')) { if ($request->filled('auto-sort')) {

View File

@ -2,7 +2,6 @@
namespace BookStack\Sorting; namespace BookStack\Sorting;
use BookStack\App\Model;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;

View File

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

View File

@ -4,7 +4,6 @@ namespace BookStack\Util;
use DOMAttr; use DOMAttr;
use DOMElement; use DOMElement;
use DOMNamedNodeMap;
use DOMNode; use DOMNode;
/** /**
@ -25,6 +24,7 @@ class HtmlDescriptionFilter
'ul' => [], 'ul' => [],
'li' => [], 'li' => [],
'strong' => [], 'strong' => [],
'span' => [],
'em' => [], 'em' => [],
'br' => [], 'br' => [],
]; ];
@ -59,7 +59,6 @@ class HtmlDescriptionFilter
return; return;
} }
/** @var DOMNamedNodeMap $attrs */
$attrs = $element->attributes; $attrs = $element->attributes;
for ($i = $attrs->length - 1; $i >= 0; $i--) { for ($i = $attrs->length - 1; $i >= 0; $i--) {
/** @var DOMAttr $attr */ /** @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) { if ($child instanceof DOMElement) {
static::filterElement($child); static::filterElement($child);
} }

View 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 {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
import {Component} from './component'; import {Component} from './component';
@ -33,7 +33,8 @@ export class Dropdown extends Component {
const menuOriginalRect = this.menu.getBoundingClientRect(); const menuOriginalRect = this.menu.getBoundingClientRect();
let heightOffset = 0; let heightOffset = 0;
const toggleHeight = this.toggle.getBoundingClientRect().height; 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(); const containerRect = this.container.getBoundingClientRect();
// If enabled, Move to body to prevent being trapped within scrollable sections // If enabled, Move to body to prevent being trapped within scrollable sections

View File

@ -1,8 +1,9 @@
import {Component} from './component'; import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom'; import {getLoading, htmlToDom} from '../services/dom';
import {buildForInput} from '../wysiwyg-tinymce/config';
import {PageCommentReference} from "./page-comment-reference"; import {PageCommentReference} from "./page-comment-reference";
import {HttpError} from "../services/http"; import {HttpError} from "../services/http";
import {SimpleWysiwygEditorInterface} from "../wysiwyg";
import {el} from "../wysiwyg/utils/dom";
export interface PageCommentReplyEventData { export interface PageCommentReplyEventData {
id: string; // ID of comment being replied to id: string; // ID of comment being replied to
@ -21,8 +22,7 @@ export class PageComment extends Component {
protected updatedText!: string; protected updatedText!: string;
protected archiveText!: string; protected archiveText!: string;
protected wysiwygEditor: any = null; protected wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
protected wysiwygLanguage!: string;
protected wysiwygTextDirection!: string; protected wysiwygTextDirection!: string;
protected container!: HTMLElement; protected container!: HTMLElement;
@ -44,7 +44,6 @@ export class PageComment extends Component {
this.archiveText = this.$opts.archiveText; this.archiveText = this.$opts.archiveText;
// Editor reference and text options // Editor reference and text options
this.wysiwygLanguage = this.$opts.wysiwygLanguage;
this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
// Element references // Element references
@ -90,7 +89,7 @@ export class PageComment extends Component {
this.form.toggleAttribute('hidden', !show); this.form.toggleAttribute('hidden', !show);
} }
protected startEdit() : void { protected async startEdit(): Promise<void> {
this.toggleEditMode(true); this.toggleEditMode(true);
if (this.wysiwygEditor) { if (this.wysiwygEditor) {
@ -98,21 +97,20 @@ export class PageComment extends Component {
return; return;
} }
const config = buildForInput({ type WysiwygModule = typeof import('../wysiwyg');
language: this.wysiwygLanguage, const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
containerElement: this.input, 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'), darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.wysiwygTextDirection, textDirection: this.$opts.textDirection,
drawioUrl: '', translations: (window as unknown as Record<string, Object>).editor_translations,
pageId: 0,
translations: {},
translationMap: (window as unknown as Record<string, Object>).editor_translations,
}); });
(window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => { this.wysiwygEditor.focus();
this.wysiwygEditor = editors[0];
setTimeout(() => this.wysiwygEditor.focus(), 50);
});
} }
protected async update(event: Event): Promise<void> { protected async update(event: Event): Promise<void> {
@ -121,7 +119,7 @@ export class PageComment extends Component {
this.form.toggleAttribute('hidden', true); this.form.toggleAttribute('hidden', true);
const reqData = { const reqData = {
html: this.wysiwygEditor.getContent(), html: await this.wysiwygEditor?.getContentAsHtml() || '',
}; };
try { try {

View File

@ -1,10 +1,11 @@
import {Component} from './component'; import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom'; import {getLoading, htmlToDom} from '../services/dom';
import {buildForInput} from '../wysiwyg-tinymce/config';
import {Tabs} from "./tabs"; import {Tabs} from "./tabs";
import {PageCommentReference} from "./page-comment-reference"; import {PageCommentReference} from "./page-comment-reference";
import {scrollAndHighlightElement} from "../services/util"; import {scrollAndHighlightElement} from "../services/util";
import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment"; import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
import {el} from "../wysiwyg/utils/dom";
import {SimpleWysiwygEditorInterface} from "../wysiwyg";
export class PageComments extends Component { export class PageComments extends Component {
@ -28,9 +29,8 @@ export class PageComments extends Component {
private hideFormButton!: HTMLElement; private hideFormButton!: HTMLElement;
private removeReplyToButton!: HTMLElement; private removeReplyToButton!: HTMLElement;
private removeReferenceButton!: HTMLElement; private removeReferenceButton!: HTMLElement;
private wysiwygLanguage!: string;
private wysiwygTextDirection!: string; private wysiwygTextDirection!: string;
private wysiwygEditor: any = null; private wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
private createdText!: string; private createdText!: string;
private countText!: string; private countText!: string;
private archivedCountText!: string; private archivedCountText!: string;
@ -63,7 +63,6 @@ export class PageComments extends Component {
this.removeReferenceButton = this.$refs.removeReferenceButton; this.removeReferenceButton = this.$refs.removeReferenceButton;
// WYSIWYG options // WYSIWYG options
this.wysiwygLanguage = this.$opts.wysiwygLanguage;
this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
// Translations // 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.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -117,7 +116,7 @@ export class PageComments extends Component {
this.form.toggleAttribute('hidden', true); this.form.toggleAttribute('hidden', true);
const reqData = { const reqData = {
html: this.wysiwygEditor.getContent(), html: (await this.wysiwygEditor?.getContentAsHtml()) || '',
parent_id: this.parentId || null, parent_id: this.parentId || null,
content_ref: this.contentReference, content_ref: this.contentReference,
}; };
@ -189,27 +188,25 @@ export class PageComments extends Component {
this.addButtonContainer.toggleAttribute('hidden', false); this.addButtonContainer.toggleAttribute('hidden', false);
} }
protected loadEditor(): void { protected async loadEditor(): Promise<void> {
if (this.wysiwygEditor) { if (this.wysiwygEditor) {
this.wysiwygEditor.focus(); this.wysiwygEditor.focus();
return; return;
} }
const config = buildForInput({ type WysiwygModule = typeof import('../wysiwyg');
language: this.wysiwygLanguage, const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
containerElement: this.formInput, 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'), darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.wysiwygTextDirection, textDirection: this.wysiwygTextDirection,
drawioUrl: '', translations: (window as unknown as Record<string, Object>).editor_translations,
pageId: 0,
translations: {},
translationMap: (window as unknown as Record<string, Object>).editor_translations,
}); });
(window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => { this.wysiwygEditor.focus();
this.wysiwygEditor = editors[0];
setTimeout(() => this.wysiwygEditor.focus(), 50);
});
} }
protected removeEditor(): void { protected removeEditor(): void {

View File

@ -1,18 +1,22 @@
import {Component} from './component'; import {Component} from './component';
export class TriLayout extends Component { export class TriLayout extends Component {
private container!: HTMLElement;
private tabs!: HTMLElement[];
private sidebarScrollContainers!: HTMLElement[];
setup() { private lastLayoutType = 'none';
this.container = this.$refs.container; private onDestroy: (()=>void)|null = null;
this.tabs = this.$manyRefs.tab; private scrollCache: Record<string, number> = {
this.lastLayoutType = 'none';
this.onDestroy = null;
this.scrollCache = {
content: 0, content: 0,
info: 0, info: 0,
}; };
this.lastTabShown = 'content'; private lastTabShown = 'content';
setup(): void {
this.container = this.$refs.container;
this.tabs = this.$manyRefs.tab;
this.sidebarScrollContainers = this.$manyRefs.sidebarScrollContainer;
// Bind any listeners // Bind any listeners
this.mobileTabClick = this.mobileTabClick.bind(this); this.mobileTabClick = this.mobileTabClick.bind(this);
@ -22,9 +26,11 @@ export class TriLayout extends Component {
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
this.updateLayout(); this.updateLayout();
}, {passive: true}); }, {passive: true});
this.setupSidebarScrollHandlers();
} }
updateLayout() { updateLayout(): void {
let newLayout = 'tablet'; let newLayout = 'tablet';
if (window.innerWidth <= 1000) newLayout = 'mobile'; if (window.innerWidth <= 1000) newLayout = 'mobile';
if (window.innerWidth > 1400) newLayout = 'desktop'; 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 * Action to run when the mobile info toggle bar is clicked/tapped
* @param event
*/ */
mobileTabClick(event) { mobileTabClick(event: MouseEvent): void {
const {tab} = event.target.dataset; const tab = (event.target as HTMLElement).dataset.tab || '';
this.showTab(tab); this.showTab(tab);
} }
@ -73,16 +78,14 @@ export class TriLayout extends Component {
* Show the content tab. * Show the content tab.
* Used by the page-display component. * Used by the page-display component.
*/ */
showContent() { showContent(): void {
this.showTab('content', false); this.showTab('content', false);
} }
/** /**
* Show the given tab * 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; this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop;
// Set tab status // Set tab status
@ -97,7 +100,7 @@ export class TriLayout extends Component {
// Set the scroll position from cache // Set the scroll position from cache
if (scroll) { if (scroll) {
const pageHeader = document.querySelector('header'); const pageHeader = document.querySelector('header') as HTMLElement;
const defaultScrollTop = pageHeader.getBoundingClientRect().bottom; const defaultScrollTop = pageHeader.getBoundingClientRect().bottom;
document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop; document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
setTimeout(() => { setTimeout(() => {
@ -108,4 +111,30 @@ export class TriLayout extends Component {
this.lastTabShown = tabName; 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);
}
}
} }

View File

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

View File

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

View File

@ -257,3 +257,21 @@ export function hashElement(element: HTMLElement): string {
const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, ''); const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
return cyrb53(normalisedElemText); 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;
}

View 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 * @typedef {Object} WysiwygConfigOptions
* @property {Element} containerElement * @property {Element} containerElement

View File

@ -1,28 +1,26 @@
import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; import {createEditor, LexicalEditor} from 'lexical';
import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {registerRichText} from '@lexical/rich-text'; import {registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils'; import {mergeRegister} from '@lexical/utils';
import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
import {buildEditorUI} from "./ui"; 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 {registerTableResizer} from "./ui/framework/helpers/table-resizer";
import {EditorUiContext} from "./ui/framework/core"; import {EditorUiContext} from "./ui/framework/core";
import {listen as listenToCommonEvents} from "./services/common-events"; import {listen as listenToCommonEvents} from "./services/common-events";
import {registerDropPasteHandling} from "./services/drop-paste-handling"; import {registerDropPasteHandling} from "./services/drop-paste-handling";
import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
import {el} from "./utils/dom";
import {registerShortcuts} from "./services/shortcuts"; import {registerShortcuts} from "./services/shortcuts";
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
import {registerKeyboardHandling} from "./services/keyboard-handling"; import {registerKeyboardHandling} from "./services/keyboard-handling";
import {registerAutoLinks} from "./services/auto-links"; 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";
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface { const theme = {
const config: CreateEditorArgs = {
namespace: 'BookStackPageEditor',
nodes: getNodesForPageEditor(),
onError: console.error,
theme: {
text: { text: {
bold: 'editor-theme-bold', bold: 'editor-theme-bold',
code: 'editor-theme-code', code: 'editor-theme-code',
@ -33,43 +31,46 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
underline: 'editor-theme-underline', underline: 'editor-theme-underline',
underlineStrikethrough: 'editor-theme-underline-strikethrough', underlineStrikethrough: 'editor-theme-underline-strikethrough',
} }
} };
};
const editArea = el('div', { export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
contenteditable: 'true', const editor = createEditor({
class: 'editor-content-area page-content', namespace: 'BookStackPageEditor',
nodes: getNodesForPageEditor(),
onError: console.error,
theme: theme,
}); });
const editWrap = el('div', { const context: EditorUiContext = buildEditorUI(container, editor, {
class: 'editor-content-wrap', ...options,
}, [editArea]); editorClass: 'page-content',
});
container.append(editWrap); editor.setRootElement(context.editorDOM);
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);
mergeRegister( mergeRegister(
registerRichText(editor), registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300), registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context), registerShortcuts(context),
registerKeyboardHandling(context), registerKeyboardHandling(context),
registerTableResizer(editor, editWrap), registerTableResizer(editor, context.scrollDOM),
registerTableSelectionHandler(editor), registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea), registerTaskListHandler(editor, context.editorDOM),
registerDropPasteHandling(context), registerDropPasteHandling(context),
registerNodeResizer(context), registerNodeResizer(context),
registerAutoLinks(editor), 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); setEditorContentFromHtml(editor, htmlContent);
const debugView = document.getElementById('lexical-debug'); const debugView = document.getElementById('lexical-debug');
@ -89,17 +90,76 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerCommonNodeMutationListeners(context); 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 { export class SimpleWysiwygEditorInterface {
protected editor: LexicalEditor; protected context: EditorUiContext;
protected onChangeListeners: (() => void)[] = [];
protected editorListenerTeardown: (() => void)|null = null;
constructor(editor: LexicalEditor) { constructor(context: EditorUiContext) {
this.editor = editor; this.context = context;
} }
async getContentAsHtml(): Promise<string> { 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();
}
});
} }
} }

View File

@ -20,9 +20,6 @@ import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
/**
* Load the nodes for lexical.
*/
export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] { export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
return [ return [
CalloutNode, 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 { export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
const decorated = [ImageNode, CodeBlockNode, DiagramNode]; const decorated = [ImageNode, CodeBlockNode, DiagramNode];
@ -53,7 +59,7 @@ export function registerCommonNodeMutationListeners(context: EditorUiContext): v
if (mutation === "destroyed") { if (mutation === "destroyed") {
const decorator = context.manager.getDecoratorByNodeKey(nodeKey); const decorator = context.manager.getDecoratorByNodeKey(nodeKey);
if (decorator) { if (decorator) {
decorator.destroy(context); decorator.teardown();
} }
} }
} }

View File

@ -79,6 +79,7 @@ import {
import {el} from "../../utils/dom"; import {el} from "../../utils/dom";
import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu"; import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu";
import {EditorSeparator} from "../framework/blocks/separator"; import {EditorSeparator} from "../framework/blocks/separator";
import {EditorContextToolbarDefinition} from "../framework/toolbars";
export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement {
@ -220,28 +221,45 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
]); ]);
} }
export function getImageToolbarContent(): EditorUiElement[] { export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
return [new EditorButton(image)]; return new EditorSimpleClassContainer('editor-toolbar-main', [
new EditorButton(bold),
new EditorButton(italic),
new EditorButton(link),
new EditorButton(bulletList),
new EditorButton(numberList),
]);
} }
export function getMediaToolbarContent(): EditorUiElement[] { export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
return [new EditorButton(media)]; image: {
} selector: 'img:not([drawio-diagram] img)',
content: () => [new EditorButton(image)],
export function getLinkToolbarContent(): EditorUiElement[] { },
media: {
selector: '.editor-media-wrap',
content: () => [new EditorButton(media)],
},
link: {
selector: 'a',
content() {
return [ return [
new EditorButton(link), new EditorButton(link),
new EditorButton(unlink), new EditorButton(unlink),
]; ]
} },
displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
export function getCodeToolbarContent(): EditorUiElement[] { const image = originalTarget.querySelector('img');
return [ return image || originalTarget;
new EditorButton(editCodeBlock), }
]; },
} code: {
selector: '.editor-code-block-wrap',
export function getTableToolbarContent(): EditorUiElement[] { content: () => [new EditorButton(editCodeBlock)],
},
table: {
selector: 'td,th',
content() {
return [ return [
new EditorOverflowContainer(2, [ new EditorOverflowContainer(2, [
new EditorButton(tableProperties), new EditorButton(tableProperties),
@ -258,12 +276,19 @@ export function getTableToolbarContent(): EditorUiElement[] {
new EditorButton(deleteColumn), new EditorButton(deleteColumn),
]), ]),
]; ];
} },
displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
export function getDetailsToolbarContent(): EditorUiElement[] { return originalTarget.closest('table') as HTMLTableElement;
}
},
details: {
selector: 'details',
content() {
return [ return [
new EditorButton(detailsEditLabel), new EditorButton(detailsEditLabel),
new EditorButton(detailsToggle), new EditorButton(detailsToggle),
new EditorButton(detailsUnwrap), new EditorButton(detailsUnwrap),
]; ]
} },
},
};

View File

@ -30,6 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef
export abstract class EditorUiElement { export abstract class EditorUiElement {
protected dom: HTMLElement|null = null; protected dom: HTMLElement|null = null;
private context: EditorUiContext|null = null; private context: EditorUiContext|null = null;
private abortController: AbortController = new AbortController();
protected abstract buildDOM(): HTMLElement; protected abstract buildDOM(): HTMLElement;
@ -79,9 +80,16 @@ export abstract class EditorUiElement {
if (target) { if (target) {
target.addEventListener('editor::' + name, ((event: CustomEvent) => { target.addEventListener('editor::' + name, ((event: CustomEvent) => {
callback(event.detail); 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 { export class EditorContainerUiElement extends EditorUiElement {
@ -129,6 +137,13 @@ export class EditorContainerUiElement extends EditorUiElement {
child.setContext(context); child.setContext(context);
} }
} }
teardown() {
for (const child of this.children) {
child.teardown();
}
super.teardown();
}
} }
export class EditorSimpleClassContainer extends EditorContainerUiElement { export class EditorSimpleClassContainer extends EditorContainerUiElement {

View File

@ -48,7 +48,7 @@ export abstract class EditorDecorator {
* Destroy this decorator. Used for tear-down operations upon destruction * Destroy this decorator. Used for tear-down operations upon destruction
* of the underlying node this decorator is attached to. * of the underlying node this decorator is attached to.
*/ */
destroy(context: EditorUiContext): void { teardown(): void {
for (const callback of this.onDestroyCallbacks) { for (const callback of this.onDestroyCallbacks) {
callback(); callback();
} }

View File

@ -41,11 +41,18 @@ export class DropDownManager {
constructor() { constructor() {
this.onMenuMouseOver = this.onMenuMouseOver.bind(this); this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
this.onWindowClick = this.onWindowClick.bind(this);
window.addEventListener('click', (event: MouseEvent) => { window.addEventListener('click', this.onWindowClick);
}
teardown(): void {
window.removeEventListener('click', this.onWindowClick);
}
protected onWindowClick(event: MouseEvent): void {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
this.closeAllNotContainingElement(target); this.closeAllNotContainingElement(target);
});
} }
protected closeAllNotContainingElement(element: HTMLElement): void { protected closeAllNotContainingElement(element: HTMLElement): void {

View File

@ -12,6 +12,8 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
export class EditorUIManager { export class EditorUIManager {
public dropdowns: DropDownManager = new DropDownManager();
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {}; protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
protected activeModalsByKey: Record<string, EditorFormModal> = {}; protected activeModalsByKey: Record<string, EditorFormModal> = {};
protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {}; protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
@ -21,12 +23,12 @@ export class EditorUIManager {
protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {}; protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
protected activeContextToolbars: EditorContextToolbar[] = []; protected activeContextToolbars: EditorContextToolbar[] = [];
protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set(); protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
protected domEventAbortController = new AbortController();
public dropdowns: DropDownManager = new DropDownManager(); protected teardownCallbacks: (()=>void)[] = [];
setContext(context: EditorUiContext) { setContext(context: EditorUiContext) {
this.context = context; this.context = context;
this.setupEventListeners(context); this.setupEventListeners();
this.setupEditor(context.editor); this.setupEditor(context.editor);
} }
@ -99,7 +101,7 @@ export class EditorUIManager {
setToolbar(toolbar: EditorContainerUiElement) { setToolbar(toolbar: EditorContainerUiElement) {
if (this.toolbar) { if (this.toolbar) {
this.toolbar.getDOMElement().remove(); this.toolbar.teardown();
} }
this.toolbar = toolbar; this.toolbar = toolbar;
@ -170,10 +172,40 @@ export class EditorUIManager {
return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr'; 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 { protected updateContextToolbars(update: EditorUiStateUpdate): void {
for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
const toolbar = this.activeContextToolbars[i]; const toolbar = this.activeContextToolbars[i];
toolbar.destroy(); toolbar.teardown();
this.activeContextToolbars.splice(i, 1); this.activeContextToolbars.splice(i, 1);
} }
@ -198,7 +230,7 @@ export class EditorUIManager {
contentByTarget.set(targetEl, []) contentByTarget.set(targetEl, [])
} }
// @ts-ignore // @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); const layoutUpdate = this.triggerLayoutUpdate.bind(this);
window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true}); window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
window.addEventListener('resize', layoutUpdate, {passive: true}); window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});
} }
} }

View File

@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement {
} }
hide() { hide() {
this.getDOMElement().remove();
this.getContext().manager.setModalInactive(this.key); this.getContext().manager.setModalInactive(this.key);
this.teardown();
} }
getForm(): EditorForm { getForm(): EditorForm {

View File

@ -4,7 +4,7 @@ import {el} from "../../utils/dom";
export type EditorContextToolbarDefinition = { export type EditorContextToolbarDefinition = {
selector: string; selector: string;
content: EditorUiElement[], content: () => EditorUiElement[],
displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement; displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement;
}; };
@ -60,17 +60,4 @@ export class EditorContextToolbar extends EditorContainerUiElement {
const dom = this.getDOMElement(); const dom = this.getDOMElement();
dom.append(...children.map(child => child.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();
}
} }

View File

@ -1,23 +1,30 @@
import {LexicalEditor} from "lexical"; import {LexicalEditor} from "lexical";
import {
getCodeToolbarContent, getDetailsToolbarContent,
getImageToolbarContent,
getLinkToolbarContent,
getMainEditorFullToolbar, getMediaToolbarContent, getTableToolbarContent
} from "./defaults/toolbars";
import {EditorUIManager} from "./framework/manager"; import {EditorUIManager} from "./framework/manager";
import {EditorUiContext} from "./framework/core"; import {EditorUiContext} from "./framework/core";
import {CodeBlockDecorator} from "./decorators/code-block"; import {el} from "../utils/dom";
import {DiagramDecorator} from "./decorators/diagram";
import {modals} from "./defaults/modals"; 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 manager = new EditorUIManager();
const context: EditorUiContext = { const context: EditorUiContext = {
editor, editor,
containerDOM: container, containerDOM: containerDOM,
editorDOM: element, editorDOM: editorDOM,
scrollDOM: scrollContainer, scrollDOM: scrollDOM,
manager, manager,
translate(text: string): string { translate(text: string): string {
const translations = options.translations; const translations = options.translations;
@ -31,50 +38,5 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
}; };
manager.setContext(context); 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; return context;
} }

View 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"}); editor.focus(() => {}, {defaultSelection: "rootStart"});
} }

View File

@ -52,6 +52,25 @@ body.editor-is-fullscreen {
flex: 1; 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 // Buttons
.editor-button { .editor-button {
font-size: 12px; font-size: 12px;

View File

@ -389,10 +389,12 @@ body.flexbox {
.tri-layout-right { .tri-layout-right {
grid-area: c; grid-area: c;
min-width: 0; min-width: 0;
position: relative;
} }
.tri-layout-left { .tri-layout-left {
grid-area: a; grid-area: a;
min-width: 0; min-width: 0;
position: relative;
} }
@include mixins.larger-than(vars.$bp-xxl) { @include mixins.larger-than(vars.$bp-xxl) {
@ -431,7 +433,8 @@ body.flexbox {
grid-template-areas: "a b b"; grid-template-areas: "a b b";
grid-template-columns: 1fr 3fr; grid-template-columns: 1fr 3fr;
grid-template-rows: min-content min-content 1fr; 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 { .tri-layout-sides {
grid-column-start: a; grid-column-start: a;
@ -452,6 +455,8 @@ body.flexbox {
height: 100%; height: 100%;
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
padding-inline: vars.$m;
margin-inline: -(vars.$m);
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
@ -521,3 +526,25 @@ body.flexbox {
margin-inline-end: 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);
}

View File

@ -1,7 +1,3 @@
@push('head')
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
@endpush
{{ csrf_field() }} {{ csrf_field() }}
<div class="form-group title-input"> <div class="form-group title-input">
<label for="name">{{ trans('common.name') }}</label> <label for="name">{{ trans('common.name') }}</label>

View File

@ -1,7 +1,3 @@
@push('head')
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
@endpush
{{ csrf_field() }} {{ csrf_field() }}
<div class="form-group title-input"> <div class="form-group title-input">
<label for="name">{{ trans('common.name') }}</label> <label for="name">{{ trans('common.name') }}</label>

View File

@ -7,7 +7,6 @@
option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}" option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
option:page-comment:deleted-text="{{ trans('entities.comment_deleted_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: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() }}" option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
id="comment{{$comment->local_id}}" id="comment{{$comment->local_id}}"
class="comment-box"> class="comment-box">

View File

@ -3,7 +3,6 @@
option:page-comments:created-text="{{ trans('entities.comment_created_success') }}" option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}" option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}"
option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}" option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}"
option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}"
option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}" option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
class="comments-list tab-container" class="comments-list tab-container"
aria-label="{{ trans('entities.comments') }}"> aria-label="{{ trans('entities.comments') }}">
@ -73,7 +72,6 @@
@if(userCan('comment-create-all') || $commentTree->canUpdateAny()) @if(userCan('comment-create-all') || $commentTree->canUpdateAny())
@push('body-end') @push('body-end')
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>
@include('form.editor-translations') @include('form.editor-translations')
@include('entities.selector-popup') @include('entities.selector-popup')
@endpush @endpush

View File

@ -1 +1 @@
@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity->tags->all()))->generateAsString() . ' ')) @push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity))->generateAsString() . ' '))

View File

@ -1,5 +1,4 @@
<textarea component="wysiwyg-input" <textarea component="wysiwyg-input"
option:wysiwyg-input:language="{{ $locale->htmlLang() }}"
option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}" option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}"
id="description_html" name="description_html" rows="5" 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> @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionHtml()}}@endif</textarea>

View File

@ -28,15 +28,15 @@
<div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') > <div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') >
<div class="tri-layout-sides print-hidden"> <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"> <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') @yield('right')
</aside> </aside>
</div> </div>
<div class="tri-layout-left print-hidden" id="sidebar"> <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') @yield('left')
</aside> </aside>
</div> </div>

View File

@ -1,7 +1,3 @@
@push('head')
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
@endpush
{{ csrf_field() }} {{ csrf_field() }}
<div class="form-group title-input"> <div class="form-group title-input">
<label for="name">{{ trans('common.name') }}</label> <label for="name">{{ trans('common.name') }}</label>

View File

@ -60,7 +60,6 @@ class CommentDisplayTest extends TestCase
$page = $this->entities->page(); $page = $this->entities->page();
$resp = $this->actingAs($editor)->get($page->getUrl()); $resp = $this->actingAs($editor)->get($page->getUrl());
$resp->assertSee('tinymce.min.js?', false);
$resp->assertSee('window.editor_translations', false); $resp->assertSee('window.editor_translations', false);
$resp->assertSee('component="entity-selector"', false); $resp->assertSee('component="entity-selector"', false);
@ -68,7 +67,6 @@ class CommentDisplayTest extends TestCase
$this->permissions->grantUserRolePermissions($editor, ['comment-update-own']); $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
$resp = $this->actingAs($editor)->get($page->getUrl()); $resp = $this->actingAs($editor)->get($page->getUrl());
$resp->assertDontSee('tinymce.min.js?', false);
$resp->assertDontSee('window.editor_translations', false); $resp->assertDontSee('window.editor_translations', false);
$resp->assertDontSee('component="entity-selector"', false); $resp->assertDontSee('component="entity-selector"', false);
@ -79,7 +77,6 @@ class CommentDisplayTest extends TestCase
]); ]);
$resp = $this->actingAs($editor)->get($page->getUrl()); $resp = $this->actingAs($editor)->get($page->getUrl());
$resp->assertSee('tinymce.min.js?', false);
$resp->assertSee('window.editor_translations', false); $resp->assertSee('window.editor_translations', false);
$resp->assertSee('component="entity-selector"', false); $resp->assertSee('component="entity-selector"', false);
} }

View File

@ -193,13 +193,14 @@ class CommentStoreTest extends TestCase
{ {
$page = $this->entities->page(); $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", [ $this->asAdmin()->postJson("/comment/$page->id", [
'html' => $script, 'html' => $script,
]); ]);
$pageView = $this->get($page->getUrl()); $pageView = $this->get($page->getUrl());
$pageView->assertDontSee($script, false); $pageView->assertDontSee($script, false);
$pageView->assertDontSee('sneakyscript', false);
$pageView->assertSee('<p>My lovely comment</p>', false); $pageView->assertSee('<p>My lovely comment</p>', false);
$comment = $page->comments()->first(); $comment = $page->comments()->first();
@ -209,6 +210,7 @@ class CommentStoreTest extends TestCase
$pageView = $this->get($page->getUrl()); $pageView = $this->get($page->getUrl());
$pageView->assertDontSee($script, false); $pageView->assertDontSee($script, false);
$pageView->assertDontSee('sneakyscript', false);
$pageView->assertSee('<p>My lovely comment</p><p>updated</p>'); $pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
} }
@ -216,7 +218,7 @@ class CommentStoreTest extends TestCase
{ {
$page = $this->entities->page(); $page = $this->entities->page();
Comment::factory()->create([ 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 'entity_type' => 'page', 'entity_id' => $page
]); ]);
@ -229,7 +231,7 @@ class CommentStoreTest extends TestCase
public function test_comment_html_is_limited() public function test_comment_html_is_limited()
{ {
$page = $this->entities->page(); $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>'; $expected = '<p>Content<a href="#cat">a</a></p>';
$resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
@ -248,4 +250,27 @@ class CommentStoreTest extends TestCase
'html' => $expected, '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,
]);
}
} }

View File

@ -230,4 +230,39 @@ class TagTest extends TestCase
$resp->assertDontSee('tag-name-<>', false); $resp->assertDontSee('tag-name-<>', false);
$resp->assertSee('tag-name-&lt;&gt;', 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');
}
} }