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:
@ -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
|
||||
|
@ -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 ($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;
|
||||
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-'));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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,7 +29,9 @@ class BookRepo
|
||||
*/
|
||||
public function create(array $input): Book
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input) {
|
||||
$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));
|
||||
@ -41,6 +44,7 @@ class BookRepo
|
||||
}
|
||||
|
||||
return $book;
|
||||
}))->run();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,6 +28,7 @@ class ChapterRepo
|
||||
*/
|
||||
public function create(array $input, Book $parentBook): Chapter
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input, $parentBook) {
|
||||
$chapter = new Chapter();
|
||||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
@ -37,6 +39,7 @@ class ChapterRepo
|
||||
$this->baseRepo->sortParent($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');
|
||||
}
|
||||
|
||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||
$chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
@ -95,5 +99,6 @@ class ChapterRepo
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
|
||||
return $parent;
|
||||
}))->run();
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
(new DatabaseTransaction(function () use ($page) {
|
||||
$page->save();
|
||||
$page->refresh()->rebuildPermissions();
|
||||
}))->run();
|
||||
|
||||
return $page;
|
||||
}
|
||||
@ -72,11 +75,13 @@ class PageRepo
|
||||
*/
|
||||
public function publishDraft(Page $draft, array $input): Page
|
||||
{
|
||||
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);
|
||||
@ -86,12 +91,13 @@ class PageRepo
|
||||
$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,6 +275,7 @@ class PageRepo
|
||||
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;
|
||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||
$page->changeBook($newBookId);
|
||||
@ -279,6 +286,7 @@ class PageRepo
|
||||
$this->baseRepo->sortParent($page);
|
||||
|
||||
return $parent;
|
||||
}))->run();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
{
|
||||
$result = (new DatabaseTransaction(function () use ($entity) {
|
||||
if ($entity instanceof Page) {
|
||||
return $this->destroyPage($entity);
|
||||
}
|
||||
if ($entity instanceof Chapter) {
|
||||
} else if ($entity instanceof Chapter) {
|
||||
return $this->destroyChapter($entity);
|
||||
}
|
||||
if ($entity instanceof Book) {
|
||||
} else if ($entity instanceof Book) {
|
||||
return $this->destroyBook($entity);
|
||||
}
|
||||
if ($entity instanceof Bookshelf) {
|
||||
} else if ($entity instanceof Bookshelf) {
|
||||
return $this->destroyShelf($entity);
|
||||
}
|
||||
return null;
|
||||
}))->run();
|
||||
|
||||
return 0;
|
||||
return $result ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,12 +155,11 @@ 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')
|
||||
@ -169,7 +168,6 @@ class JointPermissionBuilder
|
||||
->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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
|
||||
(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);
|
||||
|
||||
(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);
|
||||
|
||||
(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);
|
||||
|
||||
(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());
|
||||
|
@ -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,6 +49,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function saveNewRole(array $roleData): Role
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($roleData) {
|
||||
$role = new Role($roleData);
|
||||
$role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
|
||||
$role->save();
|
||||
@ -59,16 +61,18 @@ class PermissionsRepo
|
||||
Activity::add(ActivityType::ROLE_CREATE, $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);
|
||||
|
||||
return (new DatabaseTransaction(function () use ($role, $roleData) {
|
||||
if (isset($roleData['permissions'])) {
|
||||
$this->assignRolePermissions($role, $roleData['permissions']);
|
||||
}
|
||||
@ -80,6 +84,7 @@ class PermissionsRepo
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $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,6 +136,7 @@ class PermissionsRepo
|
||||
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
|
||||
}
|
||||
|
||||
(new DatabaseTransaction(function () use ($migrateRoleId, $role) {
|
||||
if ($migrateRoleId !== 0) {
|
||||
$newRole = Role::query()->find($migrateRoleId);
|
||||
if ($newRole) {
|
||||
@ -143,5 +149,6 @@ class PermissionsRepo
|
||||
$role->jointPermissions()->delete();
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$role->delete();
|
||||
}))->run();
|
||||
}
|
||||
}
|
||||
|
@ -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')) {
|
||||
(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.
|
||||
// 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')) {
|
||||
|
@ -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;
|
||||
|
42
app/Util/DatabaseTransaction.php
Normal file
42
app/Util/DatabaseTransaction.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -1,18 +1,22 @@
|
||||
import {Component} from './component';
|
||||
|
||||
export class TriLayout extends Component {
|
||||
private container!: HTMLElement;
|
||||
private tabs!: HTMLElement[];
|
||||
private sidebarScrollContainers!: HTMLElement[];
|
||||
|
||||
setup() {
|
||||
this.container = this.$refs.container;
|
||||
this.tabs = this.$manyRefs.tab;
|
||||
|
||||
this.lastLayoutType = 'none';
|
||||
this.onDestroy = null;
|
||||
this.scrollCache = {
|
||||
private lastLayoutType = 'none';
|
||||
private onDestroy: (()=>void)|null = null;
|
||||
private scrollCache: Record<string, number> = {
|
||||
content: 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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
}
|
32
resources/js/components/wysiwyg-input.ts
Normal file
32
resources/js/components/wysiwyg-input.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -257,3 +257,21 @@ 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;
|
||||
}
|
@ -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
|
||||
|
@ -1,28 +1,26 @@
|
||||
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";
|
||||
|
||||
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
|
||||
const config: CreateEditorArgs = {
|
||||
namespace: 'BookStackPageEditor',
|
||||
nodes: getNodesForPageEditor(),
|
||||
onError: console.error,
|
||||
theme: {
|
||||
const theme = {
|
||||
text: {
|
||||
bold: 'editor-theme-bold',
|
||||
code: 'editor-theme-code',
|
||||
@ -33,43 +31,46 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
||||
underline: 'editor-theme-underline',
|
||||
underlineStrikethrough: 'editor-theme-underline-strikethrough',
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const editArea = el('div', {
|
||||
contenteditable: 'true',
|
||||
class: 'editor-content-area page-content',
|
||||
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
|
||||
const editor = createEditor({
|
||||
namespace: 'BookStackPageEditor',
|
||||
nodes: getNodesForPageEditor(),
|
||||
onError: console.error,
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,28 +221,45 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
|
||||
]);
|
||||
}
|
||||
|
||||
export function getImageToolbarContent(): EditorUiElement[] {
|
||||
return [new EditorButton(image)];
|
||||
export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
|
||||
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[] {
|
||||
return [new EditorButton(media)];
|
||||
}
|
||||
|
||||
export function getLinkToolbarContent(): EditorUiElement[] {
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
export function getCodeToolbarContent(): EditorUiElement[] {
|
||||
return [
|
||||
new EditorButton(editCodeBlock),
|
||||
];
|
||||
}
|
||||
|
||||
export function getTableToolbarContent(): EditorUiElement[] {
|
||||
]
|
||||
},
|
||||
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),
|
||||
@ -258,12 +276,19 @@ export function getTableToolbarContent(): EditorUiElement[] {
|
||||
new EditorButton(deleteColumn),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function getDetailsToolbarContent(): EditorUiElement[] {
|
||||
},
|
||||
displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
|
||||
return originalTarget.closest('table') as HTMLTableElement;
|
||||
}
|
||||
},
|
||||
details: {
|
||||
selector: 'details',
|
||||
content() {
|
||||
return [
|
||||
new EditorButton(detailsEditLabel),
|
||||
new EditorButton(detailsToggle),
|
||||
new EditorButton(detailsUnwrap),
|
||||
];
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
};
|
@ -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 {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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) => {
|
||||
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 {
|
||||
|
@ -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});
|
||||
}
|
||||
}
|
@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement {
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.getDOMElement().remove();
|
||||
this.getContext().manager.setModalInactive(this.key);
|
||||
this.teardown();
|
||||
}
|
||||
|
||||
getForm(): EditorForm {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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"});
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
@ -521,3 +526,25 @@ body.flexbox {
|
||||
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);
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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() . ' '))
|
@ -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>
|
||||
|
@ -28,15 +28,15 @@
|
||||
<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>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -230,4 +230,39 @@ class TagTest extends TestCase
|
||||
$resp->assertDontSee('tag-name-<>', false);
|
||||
$resp->assertSee('tag-name-<>', 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');
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user