* @property int $id
* @property string $name
* @property string $value
+ * @property int $entity_id
+ * @property string $entity_type
* @property int $order
*/
class Tag extends Model
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
+ ) {
}
/**
public function generate(): array
{
$classes = [];
+ $tags = $this->entity->tags->all();
+
+ foreach ($tags as $tag) {
+ array_push($classes, ...$this->generateClassesForTag($tag));
+ }
+
+ if ($this->entity instanceof BookChild && userCan('view', $this->entity->book)) {
+ $bookTags = $this->entity->book->tags;
+ foreach ($bookTags as $bookTag) {
+ array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
+ }
+ }
- foreach ($this->tags as $tag) {
- $name = $this->normalizeTagClassString($tag->name);
- $value = $this->normalizeTagClassString($tag->value);
- $classes[] = 'tag-name-' . $name;
- if ($value) {
- $classes[] = 'tag-value-' . $value;
- $classes[] = 'tag-pair-' . $name . '-' . $value;
+ if ($this->entity instanceof Page && $this->entity->chapter && userCan('view', $this->entity->chapter)) {
+ $chapterTags = $this->entity->chapter->tags;
+ foreach ($chapterTags as $chapterTag) {
+ array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
}
}
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));
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;
$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());
}
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;
$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());
}
$entity->touch();
}
- $entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
/**
* 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
{
use BookStack\Facades\Activity;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\ImageRepo;
+use BookStack\Util\DatabaseTransaction;
use Exception;
use Illuminate\Http\UploadedFile;
*/
public function create(array $input): Book
{
- $book = new Book();
- $this->baseRepo->create($book, $input);
- $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
- $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
- Activity::add(ActivityType::BOOK_CREATE, $book);
+ return (new DatabaseTransaction(function () use ($input) {
+ $book = new Book();
- $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
- if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
- $book->sort_rule_id = $defaultBookSortSetting;
- $book->save();
- }
+ $this->baseRepo->create($book, $input);
+ $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
+ $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
+ Activity::add(ActivityType::BOOK_CREATE, $book);
- return $book;
+ $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
+ if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
+ $book->sort_rule_id = $defaultBookSortSetting;
+ $book->save();
+ }
+
+ return $book;
+ }))->run();
}
/**
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Facades\Activity;
+use BookStack\Util\DatabaseTransaction;
use Exception;
class BookshelfRepo
*/
public function create(array $input, array $bookIds): Bookshelf
{
- $shelf = new Bookshelf();
- $this->baseRepo->create($shelf, $input);
- $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
- $this->updateBooks($shelf, $bookIds);
- Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
-
- return $shelf;
+ return (new DatabaseTransaction(function () use ($input, $bookIds) {
+ $shelf = new Bookshelf();
+ $this->baseRepo->create($shelf, $input);
+ $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
+ $this->updateBooks($shelf, $bookIds);
+ Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
+ return $shelf;
+ }))->run();
}
/**
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
+use BookStack\Util\DatabaseTransaction;
use Exception;
class ChapterRepo
*/
public function create(array $input, Book $parentBook): Chapter
{
- $chapter = new Chapter();
- $chapter->book_id = $parentBook->id;
- $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
- $this->baseRepo->create($chapter, $input);
- $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
- Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
-
- $this->baseRepo->sortParent($chapter);
-
- return $chapter;
+ return (new DatabaseTransaction(function () use ($input, $parentBook) {
+ $chapter = new Chapter();
+ $chapter->book_id = $parentBook->id;
+ $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
+ $this->baseRepo->create($chapter, $input);
+ $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
+ Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
+
+ $this->baseRepo->sortParent($chapter);
+
+ return $chapter;
+ }))->run();
}
/**
throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
}
- $chapter->changeBook($parent->id);
- $chapter->rebuildPermissions();
- Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
+ return (new DatabaseTransaction(function () use ($chapter, $parent) {
+ $chapter->changeBook($parent->id);
+ $chapter->rebuildPermissions();
+ Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
- $this->baseRepo->sortParent($chapter);
+ $this->baseRepo->sortParent($chapter);
- return $parent;
+ return $parent;
+ }))->run();
}
}
use BookStack\Facades\Activity;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
+use BookStack\Util\DatabaseTransaction;
use Exception;
class PageRepo
]);
}
- $page->save();
- $page->refresh()->rebuildPermissions();
+ (new DatabaseTransaction(function () use ($page) {
+ $page->save();
+ $page->refresh()->rebuildPermissions();
+ }))->run();
return $page;
}
*/
public function publishDraft(Page $draft, array $input): Page
{
- $draft->draft = false;
- $draft->revision_count = 1;
- $draft->priority = $this->getNewPriority($draft);
- $this->updateTemplateStatusAndContentFromInput($draft, $input);
- $this->baseRepo->update($draft, $input);
-
- $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
- $this->revisionRepo->storeNewForPage($draft, $summary);
- $draft->refresh();
-
- Activity::add(ActivityType::PAGE_CREATE, $draft);
- $this->baseRepo->sortParent($draft);
-
- return $draft;
+ return (new DatabaseTransaction(function () use ($draft, $input) {
+ $draft->draft = false;
+ $draft->revision_count = 1;
+ $draft->priority = $this->getNewPriority($draft);
+ $this->updateTemplateStatusAndContentFromInput($draft, $input);
+ $this->baseRepo->update($draft, $input);
+ $draft->rebuildPermissions();
+
+ $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
+ $this->revisionRepo->storeNewForPage($draft, $summary);
+ $draft->refresh();
+
+ Activity::add(ActivityType::PAGE_CREATE, $draft);
+ $this->baseRepo->sortParent($draft);
+
+ return $draft;
+ }))->run();
}
/**
* Directly update the content for the given page from the provided input.
* Used for direct content access in a way that performs required changes
- * (Search index & reference regen) without performing an official update.
+ * (Search index and reference regen) without performing an official update.
*/
public function setContentFromInput(Page $page, array $input): void
{
$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
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
- $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
- $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
- $page->changeBook($newBookId);
- $page->rebuildPermissions();
+ return (new DatabaseTransaction(function () use ($page, $parent) {
+ $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
+ $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
+ $page->changeBook($newBookId);
+ $page->rebuildPermissions();
- Activity::add(ActivityType::PAGE_MOVE, $page);
+ Activity::add(ActivityType::PAGE_MOVE, $page);
- $this->baseRepo->sortParent($page);
+ $this->baseRepo->sortParent($page);
- return $parent;
+ return $parent;
+ }))->run();
}
/**
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
+ ) {
}
/**
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;
/**
* Destroy the given entity.
+ * Returns the number of total entities destroyed in the operation.
*
* @throws Exception
*/
public function destroyEntity(Entity $entity): int
{
- if ($entity instanceof Page) {
- return $this->destroyPage($entity);
- }
- if ($entity instanceof Chapter) {
- return $this->destroyChapter($entity);
- }
- if ($entity instanceof Book) {
- return $this->destroyBook($entity);
- }
- if ($entity instanceof Bookshelf) {
- return $this->destroyShelf($entity);
- }
+ $result = (new DatabaseTransaction(function () use ($entity) {
+ if ($entity instanceof Page) {
+ return $this->destroyPage($entity);
+ } else if ($entity instanceof Chapter) {
+ return $this->destroyChapter($entity);
+ } else if ($entity instanceof Book) {
+ return $this->destroyBook($entity);
+ } else if ($entity instanceof Bookshelf) {
+ return $this->destroyShelf($entity);
+ }
+ return null;
+ }))->run();
- return 0;
+ return $result ?? 0;
}
/**
/**
* Re-generate all entity permission from scratch.
*/
- public function rebuildForAll()
+ public function rebuildForAll(): void
{
JointPermission::query()->truncate();
/**
* 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) {
/**
* 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;
/**
* 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);
*
* @param Entity[] $entities
*/
- protected function deleteManyJointPermissionsForEntities(array $entities)
+ protected function deleteManyJointPermissionsForEntities(array $entities): void
{
$simpleEntities = $this->entitiesToSimpleEntities($entities);
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
- DB::transaction(function () use ($idsByType) {
- foreach ($idsByType as $type => $ids) {
- foreach (array_chunk($ids, 1000) as $idChunk) {
- DB::table('joint_permissions')
- ->where('entity_type', '=', $type)
- ->whereIn('entity_id', $idChunk)
- ->delete();
- }
+ foreach ($idsByType as $type => $ids) {
+ foreach (array_chunk($ids, 1000) as $idChunk) {
+ DB::table('joint_permissions')
+ ->where('entity_type', '=', $type)
+ ->whereIn('entity_id', $idChunk)
+ ->delete();
}
- });
+ }
}
/**
* @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 = [];
}
}
- DB::transaction(function () use ($jointPermissions) {
- foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
- DB::table('joint_permissions')->insert($jointPermissionChunk);
- }
- });
+ foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
+ DB::table('joint_permissions')->insert($jointPermissionChunk);
+ }
}
/**
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
$page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('restrictions-manage', $page);
- $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+ (new DatabaseTransaction(function () use ($page, $request) {
+ $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+ }))->run();
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
$chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('restrictions-manage', $chapter);
- $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+ (new DatabaseTransaction(function () use ($chapter, $request) {
+ $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+ }))->run();
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
$book = $this->queries->books->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('restrictions-manage', $book);
- $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+ (new DatabaseTransaction(function () use ($book, $request) {
+ $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+ }))->run();
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
$shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf);
- $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+ (new DatabaseTransaction(function () use ($shelf, $request) {
+ $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+ }))->run();
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
$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());
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;
*/
public function saveNewRole(array $roleData): Role
{
- $role = new Role($roleData);
- $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
- $role->save();
+ return (new DatabaseTransaction(function () use ($roleData) {
+ $role = new Role($roleData);
+ $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
+ $role->save();
- $permissions = $roleData['permissions'] ?? [];
- $this->assignRolePermissions($role, $permissions);
- $this->permissionBuilder->rebuildForRole($role);
+ $permissions = $roleData['permissions'] ?? [];
+ $this->assignRolePermissions($role, $permissions);
+ $this->permissionBuilder->rebuildForRole($role);
- Activity::add(ActivityType::ROLE_CREATE, $role);
+ Activity::add(ActivityType::ROLE_CREATE, $role);
- return $role;
+ return $role;
+ }))->run();
}
/**
* Updates an existing role.
- * Ensures Admin system role always have core permissions.
+ * Ensures the Admin system role always has core permissions.
*/
public function updateRole($roleId, array $roleData): Role
{
$role = $this->getRoleById($roleId);
- if (isset($roleData['permissions'])) {
- $this->assignRolePermissions($role, $roleData['permissions']);
- }
+ return (new DatabaseTransaction(function () use ($role, $roleData) {
+ if (isset($roleData['permissions'])) {
+ $this->assignRolePermissions($role, $roleData['permissions']);
+ }
- $role->fill($roleData);
- $role->save();
- $this->permissionBuilder->rebuildForRole($role);
+ $role->fill($roleData);
+ $role->save();
+ $this->permissionBuilder->rebuildForRole($role);
- Activity::add(ActivityType::ROLE_UPDATE, $role);
+ Activity::add(ActivityType::ROLE_UPDATE, $role);
- return $role;
+ return $role;
+ }))->run();
}
/**
/**
* 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
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
}
- if ($migrateRoleId !== 0) {
- $newRole = Role::query()->find($migrateRoleId);
- if ($newRole) {
- $users = $role->users()->pluck('id')->toArray();
- $newRole->users()->sync($users);
+ (new DatabaseTransaction(function () use ($migrateRoleId, $role) {
+ if ($migrateRoleId !== 0) {
+ $newRole = Role::query()->find($migrateRoleId);
+ if ($newRole) {
+ $users = $role->users()->pluck('id')->toArray();
+ $newRole->users()->sync($users);
+ }
}
- }
- $role->entityPermissions()->delete();
- $role->jointPermissions()->delete();
- Activity::add(ActivityType::ROLE_DELETE, $role);
- $role->delete();
+ $role->entityPermissions()->delete();
+ $role->jointPermissions()->delete();
+ Activity::add(ActivityType::ROLE_DELETE, $role);
+ $role->delete();
+ }))->run();
}
}
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
// Sort via map
if ($request->filled('sort-tree')) {
- $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
- $booksInvolved = $sorter->sortUsingMap($sortMap);
+ (new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) {
+ $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
+ $booksInvolved = $sorter->sortUsingMap($sortMap);
- // Rebuild permissions and add activity for involved books.
- foreach ($booksInvolved as $bookInvolved) {
- Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
- if ($bookInvolved->id === $book->id) {
- $loggedActivityForBook = true;
+ // Add activity for involved books.
+ foreach ($booksInvolved as $bookInvolved) {
+ Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
+ if ($bookInvolved->id === $book->id) {
+ $loggedActivityForBook = true;
+ }
}
- }
+ }))->run();
}
if ($request->filled('auto-sort')) {
namespace BookStack\Sorting;
-use BookStack\App\Model;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
--- /dev/null
+<?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);
+ }
+}
use DOMAttr;
use DOMElement;
-use DOMNamedNodeMap;
use DOMNode;
/**
'ul' => [],
'li' => [],
'strong' => [],
+ 'span' => [],
'em' => [],
'br' => [],
];
return;
}
- /** @var DOMNamedNodeMap $attrs */
$attrs = $element->attributes;
for ($i = $attrs->length - 1; $i >= 0; $i--) {
/** @var DOMAttr $attr */
}
}
- foreach ($element->childNodes as $child) {
+ $childNodes = [...$element->childNodes];
+ foreach ($childNodes as $child) {
if ($child instanceof DOMElement) {
static::filterElement($child);
}
-import {onSelect} from '../services/dom.ts';
+import {findClosestScrollContainer, onSelect} from '../services/dom.ts';
import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
import {Component} from './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
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
protected updatedText!: string;
protected archiveText!: string;
- protected wysiwygEditor: any = null;
- protected wysiwygLanguage!: string;
+ protected wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
protected wysiwygTextDirection!: string;
protected container!: HTMLElement;
this.archiveText = this.$opts.archiveText;
// Editor reference and text options
- this.wysiwygLanguage = this.$opts.wysiwygLanguage;
this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
// Element references
this.form.toggleAttribute('hidden', !show);
}
- protected startEdit() : void {
+ protected async startEdit(): Promise<void> {
this.toggleEditMode(true);
if (this.wysiwygEditor) {
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> {
this.form.toggleAttribute('hidden', true);
const reqData = {
- html: this.wysiwygEditor.getContent(),
+ html: await this.wysiwygEditor?.getContentAsHtml() || '',
};
try {
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 {
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;
this.removeReferenceButton = this.$refs.removeReferenceButton;
// WYSIWYG options
- this.wysiwygLanguage = this.$opts.wysiwygLanguage;
this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
// Translations
}
}
- protected saveComment(event: SubmitEvent): void {
+ protected async saveComment(event: SubmitEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
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,
};
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 {
import {Component} from './component';
export class TriLayout extends Component {
-
- setup() {
+ private container!: HTMLElement;
+ private tabs!: HTMLElement[];
+ private sidebarScrollContainers!: HTMLElement[];
+
+ private lastLayoutType = 'none';
+ private onDestroy: (()=>void)|null = null;
+ private scrollCache: Record<string, number> = {
+ content: 0,
+ info: 0,
+ };
+ private lastTabShown = 'content';
+
+ setup(): void {
this.container = this.$refs.container;
this.tabs = this.$manyRefs.tab;
-
- this.lastLayoutType = 'none';
- this.onDestroy = null;
- this.scrollCache = {
- content: 0,
- info: 0,
- };
- this.lastTabShown = 'content';
+ this.sidebarScrollContainers = this.$manyRefs.sidebarScrollContainer;
// Bind any listeners
this.mobileTabClick = this.mobileTabClick.bind(this);
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';
};
}
- 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);
}
* 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
// 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(() => {
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);
+ }
+ }
+
}
+++ /dev/null
-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];
- });
- }
-
-}
--- /dev/null
+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;
+ });
+ });
+ }
+}
export function hashElement(element: HTMLElement): string {
const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
return cyrb53(normalisedElemText);
+}
+
+/**
+ * Find the closest scroll container parent for the given element
+ * otherwise will default to the body element.
+ */
+export function findClosestScrollContainer(start: HTMLElement): HTMLElement {
+ let el: HTMLElement|null = start;
+ do {
+ const computed = window.getComputedStyle(el);
+ if (computed.overflowY === 'scroll') {
+ return el;
+ }
+
+ el = el.parentElement;
+ } while (el);
+
+ return document.body;
}
\ No newline at end of file
};
}
-/**
- * @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
-import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical';
+import {createEditor, LexicalEditor} from 'lexical';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
-import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
+import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
import {buildEditorUI} from "./ui";
-import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
+import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
import {EditorUiContext} from "./ui/framework/core";
import {listen as listenToCommonEvents} from "./services/common-events";
import {registerDropPasteHandling} from "./services/drop-paste-handling";
import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
-import {el} from "./utils/dom";
import {registerShortcuts} from "./services/shortcuts";
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
import {registerKeyboardHandling} from "./services/keyboard-handling";
import {registerAutoLinks} from "./services/auto-links";
+import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
+import {modals} from "./ui/defaults/modals";
+import {CodeBlockDecorator} from "./ui/decorators/code-block";
+import {DiagramDecorator} from "./ui/decorators/diagram";
+
+const theme = {
+ text: {
+ bold: 'editor-theme-bold',
+ code: 'editor-theme-code',
+ italic: 'editor-theme-italic',
+ strikethrough: 'editor-theme-strikethrough',
+ subscript: 'editor-theme-subscript',
+ superscript: 'editor-theme-superscript',
+ underline: 'editor-theme-underline',
+ underlineStrikethrough: 'editor-theme-underline-strikethrough',
+ }
+};
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
- const config: CreateEditorArgs = {
+ const editor = createEditor({
namespace: 'BookStackPageEditor',
nodes: getNodesForPageEditor(),
onError: console.error,
- theme: {
- text: {
- bold: 'editor-theme-bold',
- code: 'editor-theme-code',
- italic: 'editor-theme-italic',
- strikethrough: 'editor-theme-strikethrough',
- subscript: 'editor-theme-subscript',
- superscript: 'editor-theme-superscript',
- underline: 'editor-theme-underline',
- underlineStrikethrough: 'editor-theme-underline-strikethrough',
- }
- }
- };
-
- const editArea = el('div', {
- contenteditable: 'true',
- class: 'editor-content-area page-content',
+ theme: theme,
});
- const editWrap = el('div', {
- class: 'editor-content-wrap',
- }, [editArea]);
-
- container.append(editWrap);
- container.classList.add('editor-container');
- container.setAttribute('dir', options.textDirection);
- if (options.darkMode) {
- container.classList.add('editor-dark');
- }
-
- const editor = createEditor(config);
- editor.setRootElement(editArea);
- const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options);
+ const context: EditorUiContext = buildEditorUI(container, editor, {
+ ...options,
+ editorClass: 'page-content',
+ });
+ editor.setRootElement(context.editorDOM);
mergeRegister(
registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context),
registerKeyboardHandling(context),
- registerTableResizer(editor, editWrap),
+ registerTableResizer(editor, context.scrollDOM),
registerTableSelectionHandler(editor),
- registerTaskListHandler(editor, editArea),
+ registerTaskListHandler(editor, context.editorDOM),
registerDropPasteHandling(context),
registerNodeResizer(context),
registerAutoLinks(editor),
);
- listenToCommonEvents(editor);
+ // Register toolbars, modals & decorators
+ context.manager.setToolbar(getMainEditorFullToolbar(context));
+ for (const key of Object.keys(contextToolbars)) {
+ context.manager.registerContextToolbar(key, contextToolbars[key]);
+ }
+ for (const key of Object.keys(modals)) {
+ context.manager.registerModal(key, modals[key]);
+ }
+ context.manager.registerDecoratorType('code', CodeBlockDecorator);
+ context.manager.registerDecoratorType('diagram', DiagramDecorator);
+ listenToCommonEvents(editor);
setEditorContentFromHtml(editor, htmlContent);
const debugView = document.getElementById('lexical-debug');
registerCommonNodeMutationListeners(context);
- return new SimpleWysiwygEditorInterface(editor);
+ return new SimpleWysiwygEditorInterface(context);
+}
+
+export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
+ const editor = createEditor({
+ namespace: 'BookStackBasicEditor',
+ nodes: getNodesForBasicEditor(),
+ onError: console.error,
+ theme: theme,
+ });
+ const context: EditorUiContext = buildEditorUI(container, editor, options);
+ editor.setRootElement(context.editorDOM);
+
+ const editorTeardown = mergeRegister(
+ registerRichText(editor),
+ registerHistory(editor, createEmptyHistoryState(), 300),
+ registerShortcuts(context),
+ registerAutoLinks(editor),
+ );
+
+ // Register toolbars, modals & decorators
+ context.manager.setToolbar(getBasicEditorToolbar(context));
+ context.manager.registerContextToolbar('link', contextToolbars.link);
+ context.manager.registerModal('link', modals.link);
+ context.manager.onTeardown(editorTeardown);
+
+ setEditorContentFromHtml(editor, htmlContent);
+
+ return new SimpleWysiwygEditorInterface(context);
}
export class SimpleWysiwygEditorInterface {
- protected editor: LexicalEditor;
+ protected context: EditorUiContext;
+ protected onChangeListeners: (() => void)[] = [];
+ protected editorListenerTeardown: (() => void)|null = null;
- constructor(editor: LexicalEditor) {
- this.editor = editor;
+ constructor(context: EditorUiContext) {
+ this.context = context;
}
async getContentAsHtml(): Promise<string> {
- return await getEditorContentAsHtml(this.editor);
+ return await getEditorContentAsHtml(this.context.editor);
+ }
+
+ onChange(listener: () => void) {
+ this.onChangeListeners.push(listener);
+ this.startListeningToChanges();
+ }
+
+ focus(): void {
+ focusEditor(this.context.editor);
+ }
+
+ remove() {
+ this.context.manager.teardown();
+ this.context.containerDOM.remove();
+ if (this.editorListenerTeardown) {
+ this.editorListenerTeardown();
+ }
+ }
+
+ protected startListeningToChanges(): void {
+ if (this.editorListenerTeardown) {
+ return;
+ }
+
+ this.editorListenerTeardown = this.context.editor.registerUpdateListener(() => {
+ for (const listener of this.onChangeListeners) {
+ listener();
+ }
+ });
}
}
\ No newline at end of file
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,
];
}
+export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
+ return [
+ ListNode,
+ ListItemNode,
+ ParagraphNode,
+ LinkNode,
+ ];
+}
+
export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
const decorated = [ImageNode, CodeBlockNode, DiagramNode];
if (mutation === "destroyed") {
const decorator = context.manager.getDecoratorByNodeKey(nodeKey);
if (decorator) {
- decorator.destroy(context);
+ decorator.teardown();
}
}
}
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 {
]);
}
-export function getImageToolbarContent(): EditorUiElement[] {
- return [new EditorButton(image)];
-}
-
-export function getMediaToolbarContent(): EditorUiElement[] {
- return [new EditorButton(media)];
-}
-
-export function getLinkToolbarContent(): EditorUiElement[] {
- return [
+export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
+ return new EditorSimpleClassContainer('editor-toolbar-main', [
+ new EditorButton(bold),
+ new EditorButton(italic),
new EditorButton(link),
- new EditorButton(unlink),
- ];
-}
-
-export function getCodeToolbarContent(): EditorUiElement[] {
- return [
- new EditorButton(editCodeBlock),
- ];
-}
-
-export function getTableToolbarContent(): EditorUiElement[] {
- return [
- new EditorOverflowContainer(2, [
- new EditorButton(tableProperties),
- new EditorButton(deleteTable),
- ]),
- new EditorOverflowContainer(3, [
- new EditorButton(insertRowAbove),
- new EditorButton(insertRowBelow),
- new EditorButton(deleteRow),
- ]),
- new EditorOverflowContainer(3, [
- new EditorButton(insertColumnBefore),
- new EditorButton(insertColumnAfter),
- new EditorButton(deleteColumn),
- ]),
- ];
+ new EditorButton(bulletList),
+ new EditorButton(numberList),
+ ]);
}
-export function getDetailsToolbarContent(): EditorUiElement[] {
- return [
- new EditorButton(detailsEditLabel),
- new EditorButton(detailsToggle),
- new EditorButton(detailsUnwrap),
- ];
-}
\ No newline at end of file
+export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
+ image: {
+ selector: 'img:not([drawio-diagram] img)',
+ content: () => [new EditorButton(image)],
+ },
+ media: {
+ selector: '.editor-media-wrap',
+ content: () => [new EditorButton(media)],
+ },
+ link: {
+ selector: 'a',
+ content() {
+ return [
+ new EditorButton(link),
+ new EditorButton(unlink),
+ ]
+ },
+ displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
+ const image = originalTarget.querySelector('img');
+ return image || originalTarget;
+ }
+ },
+ code: {
+ selector: '.editor-code-block-wrap',
+ content: () => [new EditorButton(editCodeBlock)],
+ },
+ table: {
+ selector: 'td,th',
+ content() {
+ return [
+ new EditorOverflowContainer(2, [
+ new EditorButton(tableProperties),
+ new EditorButton(deleteTable),
+ ]),
+ new EditorOverflowContainer(3, [
+ new EditorButton(insertRowAbove),
+ new EditorButton(insertRowBelow),
+ new EditorButton(deleteRow),
+ ]),
+ new EditorOverflowContainer(3, [
+ new EditorButton(insertColumnBefore),
+ new EditorButton(insertColumnAfter),
+ new EditorButton(deleteColumn),
+ ]),
+ ];
+ },
+ displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
+ return originalTarget.closest('table') as HTMLTableElement;
+ }
+ },
+ details: {
+ selector: 'details',
+ content() {
+ return [
+ new EditorButton(detailsEditLabel),
+ new EditorButton(detailsToggle),
+ new EditorButton(detailsUnwrap),
+ ]
+ },
+ },
+};
\ No newline at end of file
export abstract class EditorUiElement {
protected dom: HTMLElement|null = null;
private context: EditorUiContext|null = null;
+ private abortController: AbortController = new AbortController();
protected abstract buildDOM(): HTMLElement;
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 {
child.setContext(context);
}
}
+
+ teardown() {
+ for (const child of this.children) {
+ child.teardown();
+ }
+ super.teardown();
+ }
}
export class EditorSimpleClassContainer extends EditorContainerUiElement {
* 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();
}
constructor() {
this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
+ this.onWindowClick = this.onWindowClick.bind(this);
- window.addEventListener('click', (event: MouseEvent) => {
- const target = event.target as HTMLElement;
- this.closeAllNotContainingElement(target);
- });
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ teardown(): void {
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ protected onWindowClick(event: MouseEvent): void {
+ const target = event.target as HTMLElement;
+ this.closeAllNotContainingElement(target);
}
protected closeAllNotContainingElement(element: HTMLElement): void {
export class EditorUIManager {
+ public dropdowns: DropDownManager = new DropDownManager();
+
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
protected activeModalsByKey: Record<string, EditorFormModal> = {};
protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
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);
}
setToolbar(toolbar: EditorContainerUiElement) {
if (this.toolbar) {
- this.toolbar.getDOMElement().remove();
+ this.toolbar.teardown();
}
this.toolbar = toolbar;
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);
}
contentByTarget.set(targetEl, [])
}
// @ts-ignore
- contentByTarget.get(targetEl).push(...definition.content);
+ contentByTarget.get(targetEl).push(...definition.content());
}
}
});
}
- protected setupEventListeners(context: EditorUiContext) {
+ protected setupEventListeners() {
const layoutUpdate = this.triggerLayoutUpdate.bind(this);
- window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
- window.addEventListener('resize', layoutUpdate, {passive: true});
+ window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
+ window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});
}
}
\ No newline at end of file
}
hide() {
- this.getDOMElement().remove();
this.getContext().manager.setModalInactive(this.key);
+ this.teardown();
}
getForm(): EditorForm {
export type EditorContextToolbarDefinition = {
selector: string;
- content: EditorUiElement[],
+ content: () => EditorUiElement[],
displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement;
};
const dom = this.getDOMElement();
dom.append(...children.map(child => child.getDOMElement()));
}
-
- protected empty() {
- const children = this.getChildren();
- for (const child of children) {
- child.getDOMElement().remove();
- }
- this.removeChildren(...children);
- }
-
- destroy() {
- this.empty();
- this.getDOMElement().remove();
- }
}
\ No newline at end of file
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;
};
manager.setContext(context);
- // Create primary toolbar
- manager.setToolbar(getMainEditorFullToolbar(context));
-
- // Register modals
- for (const key of Object.keys(modals)) {
- manager.registerModal(key, modals[key]);
- }
-
- // Register context toolbars
- manager.registerContextToolbar('image', {
- selector: 'img:not([drawio-diagram] img)',
- content: getImageToolbarContent(),
- });
- manager.registerContextToolbar('media', {
- selector: '.editor-media-wrap',
- content: getMediaToolbarContent(),
- });
- manager.registerContextToolbar('link', {
- selector: 'a',
- content: getLinkToolbarContent(),
- displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
- const image = originalTarget.querySelector('img');
- return image || originalTarget;
- }
- });
- manager.registerContextToolbar('code', {
- selector: '.editor-code-block-wrap',
- content: getCodeToolbarContent(),
- });
- manager.registerContextToolbar('table', {
- selector: 'td,th',
- content: getTableToolbarContent(),
- displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
- return originalTarget.closest('table') as HTMLTableElement;
- }
- });
- manager.registerContextToolbar('details', {
- selector: 'details',
- content: getDetailsToolbarContent(),
- });
-
- // Register image decorator listener
- manager.registerDecoratorType('code', CodeBlockDecorator);
- manager.registerDecoratorType('diagram', DiagramDecorator);
-
return context;
}
\ No newline at end of file
});
}
-export function focusEditor(editor: LexicalEditor) {
+export function focusEditor(editor: LexicalEditor): void {
editor.focus(() => {}, {defaultSelection: "rootStart"});
}
\ No newline at end of file
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;
.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) {
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;
height: 100%;
scrollbar-width: none;
-ms-overflow-style: none;
+ padding-inline: vars.$m;
+ margin-inline: -(vars.$m);
&::-webkit-scrollbar {
display: none;
}
margin-inline-start: 0;
margin-inline-end: 0;
}
+}
+
+/**
+ * Scroll Indicators
+ */
+.scroll-away-from-top:before,
+.scroll-away-from-bottom:after {
+ content: '';
+ display: block;
+ position: absolute;
+ @include mixins.lightDark(color, #F2F2F2, #111);
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 50px;
+ background: linear-gradient(to bottom, currentColor, transparent);
+ z-index: 2;
+}
+.scroll-away-from-bottom:after {
+ top: auto;
+ bottom: 0;
+ background: linear-gradient(to top, currentColor, transparent);
}
\ No newline at end of file
-@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>
-@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>
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">
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') }}">
@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
-@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity->tags->all()))->generateAsString() . ' '))
\ No newline at end of file
+@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity))->generateAsString() . ' '))
\ No newline at end of file
<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>
<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>
-@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>
$page = $this->entities->page();
$resp = $this->actingAs($editor)->get($page->getUrl());
- $resp->assertSee('tinymce.min.js?', false);
$resp->assertSee('window.editor_translations', false);
$resp->assertSee('component="entity-selector"', false);
$this->permissions->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);
]);
$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);
}
{
$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();
$pageView = $this->get($page->getUrl());
$pageView->assertDontSee($script, false);
+ $pageView->assertDontSee('sneakyscript', false);
$pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
}
{
$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
]);
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]);
'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,
+ ]);
+ }
}
$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');
+ }
}