Merge branch 'development' into release
This commit is contained in:
commit
914790fd99
|
@ -291,3 +291,10 @@ Fabrice Boyer (FabriceBoyer) :: French
|
|||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German
|
||||
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
|
||||
Jan Mitrof (jan.kachlik) :: Czech
|
||||
edwardsmirnov :: Russian
|
||||
Mr_OSS117 :: French
|
||||
shotu :: French
|
||||
Cesar_Lopez_Aguillon :: Spanish
|
||||
bdewoop :: German
|
||||
dina davoudi (dina.davoudi) :: Persian
|
||||
|
|
|
@ -18,10 +18,10 @@ jobs:
|
|||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
|
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
|
@ -21,10 +21,10 @@ jobs:
|
|||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
|
@ -21,10 +21,10 @@ jobs:
|
|||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Actions\Queries;
|
||||
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the webhooks in the system in a paginated format.
|
||||
*/
|
||||
class WebhooksAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$query = Webhook::query()->select(['*'])
|
||||
->withCount(['trackedEvents'])
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('endpoint', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ namespace BookStack\Actions;
|
|||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
@ -20,8 +21,14 @@ class TagRepo
|
|||
/**
|
||||
* Start a query against all tags in the system.
|
||||
*/
|
||||
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'name' && $nameFilter) {
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
|
@ -32,7 +39,7 @@ class TagRepo
|
|||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||
])
|
||||
->orderBy($nameFilter ? 'value' : 'name');
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($nameFilter) {
|
||||
$query->where('name', '=', $nameFilter);
|
||||
|
|
|
@ -4,21 +4,29 @@ namespace BookStack\Api;
|
|||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ListingResponseBuilder
|
||||
{
|
||||
protected $query;
|
||||
protected $request;
|
||||
protected $fields;
|
||||
protected Builder $query;
|
||||
protected Request $request;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $fields;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
protected $resultModifiers = [];
|
||||
protected array $resultModifiers = [];
|
||||
|
||||
protected $filterOperators = [
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $filterOperators = [
|
||||
'eq' => '=',
|
||||
'ne' => '!=',
|
||||
'gt' => '>',
|
||||
|
@ -62,9 +70,9 @@ class ListingResponseBuilder
|
|||
/**
|
||||
* Add a callback to modify each element of the results.
|
||||
*
|
||||
* @param (callable(Model)) $modifier
|
||||
* @param (callable(Model): void) $modifier
|
||||
*/
|
||||
public function modifyResults($modifier): void
|
||||
public function modifyResults(callable $modifier): void
|
||||
{
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
|
|
@ -67,11 +67,10 @@ class OidcJwtSigningKey
|
|||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
||||
}
|
||||
|
||||
if (empty($jwk['use'])) {
|
||||
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
if ($jwk['use'] !== 'sig') {
|
||||
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
|
||||
// the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play.
|
||||
$use = $jwk['use'] ?? 'sig';
|
||||
if ($use !== 'sig') {
|
||||
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
|
||||
}
|
||||
|
||||
|
|
|
@ -15,40 +15,17 @@ use Psr\Http\Client\ClientInterface;
|
|||
*/
|
||||
class OidcProviderSettings
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $issuer;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientId;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientSecret;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $redirectUri;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $tokenEndpoint;
|
||||
public string $issuer;
|
||||
public string $clientId;
|
||||
public string $clientSecret;
|
||||
public ?string $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
*/
|
||||
public $keys = [];
|
||||
public ?array $keys = [];
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
|
@ -164,9 +141,10 @@ class OidcProviderSettings
|
|||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
$alg = $key['alg'] ?? null;
|
||||
$alg = $key['alg'] ?? 'RS256';
|
||||
$use = $key['use'] ?? 'sig';
|
||||
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
|
||||
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,6 @@ class OidcService
|
|||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'state' => $provider->getState(),
|
||||
|
|
|
@ -22,7 +22,7 @@ class JointPermissionBuilder
|
|||
/**
|
||||
* @var array<string, array<int, SimpleEntityData>>
|
||||
*/
|
||||
protected $entityCache;
|
||||
protected array $entityCache;
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
|
@ -230,7 +230,7 @@ class JointPermissionBuilder
|
|||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
* @param Entity[] $originalEntities
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function createManyJointPermissions(array $originalEntities, array $roles)
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the roles in the system in a paginated format.
|
||||
*/
|
||||
class RolesAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = Role::query()->select(['*'])
|
||||
->withCount(['users', 'permissions'])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('display_name', 'like', $term)
|
||||
->orWhere('description', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
|
@ -11,23 +12,23 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
|||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
class AllUsersPaginatedAndSorted
|
||||
class UsersAllPaginatedAndSorted
|
||||
{
|
||||
/**
|
||||
* @param array{sort: string, order: string, search: string} $sortData
|
||||
*/
|
||||
public function run(int $count, array $sortData): LengthAwarePaginator
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $sortData['sort'];
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $sortData['order']);
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($sortData['search']) {
|
||||
$term = '%' . $sortData['search'] . '%';
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
|
@ -110,14 +110,6 @@ class Role extends Model implements Loggable
|
|||
return static::query()->where('system_name', '=', $systemName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all visible roles.
|
||||
*/
|
||||
public static function visible(): Collection
|
||||
{
|
||||
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
|
|
@ -158,6 +158,9 @@ class UserRepo
|
|||
// Delete user profile images
|
||||
$this->userAvatar->destroyAllForUser($user);
|
||||
|
||||
// Delete related activities
|
||||
setting()->deleteUserSettings($user->id);
|
||||
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
|
|
|
@ -75,7 +75,7 @@ return [
|
|||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
|
|
|
@ -26,6 +26,8 @@ return [
|
|||
|
||||
// User-level default settings
|
||||
'user' => [
|
||||
'ui-shortcuts' => '{}',
|
||||
'ui-shortcuts-enabled' => false,
|
||||
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
|
||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||
|
|
|
@ -88,8 +88,6 @@ class Page extends BookChild
|
|||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function currentRevision(): HasOne
|
||||
{
|
||||
|
|
|
@ -87,14 +87,14 @@ class BaseRepo
|
|||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
if ($removeImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$entity->image_id = 0;
|
||||
$entity->save();
|
||||
}
|
||||
|
|
|
@ -181,7 +181,7 @@ class BookContents
|
|||
$model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($chapterChanged) {
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
|
@ -235,7 +235,7 @@ class BookContents
|
|||
}
|
||||
|
||||
$hasPageEditPermission = userCan('page-update', $model);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
|
||||
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
|
||||
$hasNewParentPermission = userCan($newParentPermission, $newParent);
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
|
|||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
|
@ -109,9 +110,11 @@ class Cloner
|
|||
$inputData['tags'] = $this->entityTagsToInputArray($entity);
|
||||
|
||||
// Add a cover to the data if existing on the original entity
|
||||
if ($entity->cover instanceof Image) {
|
||||
$uploadedFile = $this->imageToUploadedFile($entity->cover);
|
||||
$inputData['image'] = $uploadedFile;
|
||||
if ($entity instanceof HasCoverImage) {
|
||||
$cover = $entity->cover()->first();
|
||||
if ($cover) {
|
||||
$inputData['image'] = $this->imageToUploadedFile($cover);
|
||||
}
|
||||
}
|
||||
|
||||
return $inputData;
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\Activity;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
@ -13,10 +15,15 @@ class AuditLogController extends Controller
|
|||
$this->checkPermission('settings-manage');
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$listDetails = [
|
||||
'order' => $request->get('order', 'desc'),
|
||||
$sort = $request->get('sort', 'activity_date');
|
||||
$order = $request->get('order', 'desc');
|
||||
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
|
||||
'created_at' => trans('settings.audit_table_date'),
|
||||
'type' => trans('settings.audit_table_event'),
|
||||
]);
|
||||
|
||||
$filters = [
|
||||
'event' => $request->get('event', ''),
|
||||
'sort' => $request->get('sort', 'created_at'),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
|
@ -25,39 +32,38 @@ class AuditLogController extends Controller
|
|||
|
||||
$query = Activity::query()
|
||||
->with([
|
||||
'entity' => function ($query) {
|
||||
$query->withTrashed();
|
||||
},
|
||||
'entity' => fn ($query) => $query->withTrashed(),
|
||||
'user',
|
||||
])
|
||||
->orderBy($listDetails['sort'], $listDetails['order']);
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($listDetails['event']) {
|
||||
$query->where('type', '=', $listDetails['event']);
|
||||
if ($filters['event']) {
|
||||
$query->where('type', '=', $filters['event']);
|
||||
}
|
||||
if ($listDetails['user']) {
|
||||
$query->where('user_id', '=', $listDetails['user']);
|
||||
if ($filters['user']) {
|
||||
$query->where('user_id', '=', $filters['user']);
|
||||
}
|
||||
|
||||
if ($listDetails['date_from']) {
|
||||
$query->where('created_at', '>=', $listDetails['date_from']);
|
||||
if ($filters['date_from']) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if ($listDetails['date_to']) {
|
||||
$query->where('created_at', '<=', $listDetails['date_to']);
|
||||
if ($filters['date_to']) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
if ($listDetails['ip']) {
|
||||
$query->where('ip', 'like', $listDetails['ip'] . '%');
|
||||
if ($filters['ip']) {
|
||||
$query->where('ip', 'like', $filters['ip'] . '%');
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($listDetails);
|
||||
$activities->appends($request->all());
|
||||
|
||||
$types = DB::table('activities')->select('type')->distinct()->pluck('type');
|
||||
$types = ActivityType::all();
|
||||
$this->setPageTitle(trans('settings.audit'));
|
||||
|
||||
return view('settings.audit', [
|
||||
'activities' => $activities,
|
||||
'listDetails' => $listDetails,
|
||||
'filters' => $filters,
|
||||
'listOptions' => $listOptions,
|
||||
'activityTypes' => $types,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -51,14 +51,28 @@ class ConfirmEmailController extends Controller
|
|||
return view('auth.user-unconfirmed', ['user' => $user]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for a user to provide their positive confirmation of their email.
|
||||
*/
|
||||
public function showAcceptForm(string $token)
|
||||
{
|
||||
return view('auth.register-confirm-accept', ['token' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms an email via a token and logs the user into the system.
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirm(string $token)
|
||||
public function confirm(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'token' => ['required', 'string']
|
||||
]);
|
||||
|
||||
$token = $validated['token'];
|
||||
|
||||
try {
|
||||
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
|
||||
} catch (UserTokenNotFoundException $exception) {
|
||||
|
|
|
@ -15,6 +15,7 @@ use BookStack\Exceptions\ImageUploadException;
|
|||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
@ -35,13 +36,16 @@ class BookController extends Controller
|
|||
/**
|
||||
* Display a listing of the book.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$view = setting()->getForCurrentUser('books_view_type');
|
||||
$sort = setting()->getForCurrentUser('books_sort', 'name');
|
||||
$order = setting()->getForCurrentUser('books_sort_order', 'asc');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'books')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$books = $this->bookRepo->getAllPaginated(18, $sort, $order);
|
||||
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->bookRepo->getPopular(4);
|
||||
$new = $this->bookRepo->getRecentlyCreated(4);
|
||||
|
@ -56,8 +60,7 @@ class BookController extends Controller
|
|||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ use BookStack\Entities\Tools\ShelfContext;
|
|||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
@ -30,18 +31,16 @@ class BookshelfController extends Controller
|
|||
/**
|
||||
* Display a listing of the book.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$view = setting()->getForCurrentUser('bookshelves_view_type');
|
||||
$sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
|
||||
$order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
|
||||
$sortOptions = [
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'bookshelves')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
];
|
||||
]);
|
||||
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $sort, $order);
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->shelfRepo->getPopular(4);
|
||||
$new = $this->shelfRepo->getRecentlyCreated(4);
|
||||
|
@ -55,9 +54,7 @@ class BookshelfController extends Controller
|
|||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -100,16 +97,21 @@ class BookshelfController extends Controller
|
|||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function show(ActivityQueries $activities, string $slug)
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('bookshelf-view', $shelf);
|
||||
|
||||
$sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
|
||||
$order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
'default' => trans('common.sort_default'),
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$sort = $listOptions->getSort();
|
||||
$sortedVisibleShelfBooks = $shelf->visibleBooks()->get()
|
||||
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $order === 'desc')
|
||||
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $listOptions->getOrder() === 'desc')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
|
@ -124,8 +126,7 @@ class BookshelfController extends Controller
|
|||
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
|
||||
'view' => $view,
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'order' => $order,
|
||||
'sort' => $sort,
|
||||
'listOptions' => $listOptions,
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -10,13 +10,15 @@ use BookStack\Entities\Queries\TopFavourites;
|
|||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index(ActivityQueries $activities)
|
||||
public function index(Request $request, ActivityQueries $activities)
|
||||
{
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
@ -61,33 +63,27 @@ class HomeController extends Controller
|
|||
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
|
||||
$key = $homepageOption;
|
||||
$view = setting()->getForCurrentUser($key . '_view_type');
|
||||
$sort = setting()->getForCurrentUser($key . '_sort', 'name');
|
||||
$order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
|
||||
|
||||
$sortOptions = [
|
||||
$listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
];
|
||||
]);
|
||||
|
||||
$commonData = array_merge($commonData, [
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'books') {
|
||||
$bookRepo = app(BookRepo::class);
|
||||
$books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
|
||||
$books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
|
|
|
@ -3,10 +3,13 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Ssddanbrown\HtmlDiff\Diff;
|
||||
|
||||
class PageRevisionController extends Controller
|
||||
|
@ -23,22 +26,29 @@ class PageRevisionController extends Controller
|
|||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function index(string $bookSlug, string $pageSlug)
|
||||
public function index(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
|
||||
'id' => trans('entities.pages_revisions_sort_number')
|
||||
]);
|
||||
|
||||
$revisions = $page->revisions()->select([
|
||||
'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
|
||||
'type', 'revision_number', 'summary',
|
||||
])
|
||||
->selectRaw("IF(markdown = '', false, true) as is_markdown")
|
||||
->with(['page.book', 'createdBy'])
|
||||
->get();
|
||||
->reorder('id', $listOptions->getOrder())
|
||||
->reorder('created_at', $listOptions->getOrder())
|
||||
->paginate(50);
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
|
||||
|
||||
return view('pages.revisions', [
|
||||
'revisions' => $revisions,
|
||||
'page' => $page,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -50,6 +60,7 @@ class PageRevisionController extends Controller
|
|||
public function show(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
if ($revision === null) {
|
||||
throw new NotFoundException();
|
||||
|
@ -78,6 +89,7 @@ class PageRevisionController extends Controller
|
|||
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
if ($revision === null) {
|
||||
throw new NotFoundException();
|
||||
|
|
|
@ -3,19 +3,18 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Auth\Queries\RolesAllPaginatedAndSorted;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
protected $permissionsRepo;
|
||||
protected PermissionsRepo $permissionsRepo;
|
||||
|
||||
/**
|
||||
* PermissionController constructor.
|
||||
*/
|
||||
public function __construct(PermissionsRepo $permissionsRepo)
|
||||
{
|
||||
$this->permissionsRepo = $permissionsRepo;
|
||||
|
@ -24,14 +23,27 @@ class RoleController extends Controller
|
|||
/**
|
||||
* Show a listing of the roles in the system.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$roles = $this->permissionsRepo->getAllRoles();
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'roles')->withSortOptions([
|
||||
'display_name' => trans('common.sort_name'),
|
||||
'users_count' => trans('settings.roles_assigned_users'),
|
||||
'permissions_count' => trans('settings.roles_permissions_provided'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$roles = (new RolesAllPaginatedAndSorted())->run(20, $listOptions);
|
||||
$roles->appends($listOptions->getPaginationAppends());
|
||||
|
||||
$this->setPageTitle(trans('settings.roles'));
|
||||
|
||||
return view('settings.roles.index', ['roles' => $roles]);
|
||||
return view('settings.roles.index', [
|
||||
'roles' => $roles,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,16 +87,11 @@ class RoleController extends Controller
|
|||
|
||||
/**
|
||||
* Show the form for editing a user role.
|
||||
*
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
if ($role->hidden) {
|
||||
throw new PermissionsException(trans('errors.role_cannot_be_edited'));
|
||||
}
|
||||
|
||||
$this->setPageTitle(trans('settings.role_edit'));
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ use Illuminate\Http\Request;
|
|||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
protected $searchRunner;
|
||||
protected SearchRunner $searchRunner;
|
||||
|
||||
public function __construct(SearchRunner $searchRunner)
|
||||
{
|
||||
|
@ -69,7 +69,7 @@ class SearchController extends Controller
|
|||
* Search for a list of entities and return a partial HTML response of matching entities.
|
||||
* Returns the most popular entities if no search is provided.
|
||||
*/
|
||||
public function searchEntitiesAjax(Request $request)
|
||||
public function searchForSelector(Request $request)
|
||||
{
|
||||
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
|
||||
$searchTerm = $request->get('term', false);
|
||||
|
@ -83,7 +83,25 @@ class SearchController extends Controller
|
|||
$entities = (new Popular())->run(20, 0, $entityTypes);
|
||||
}
|
||||
|
||||
return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
|
||||
return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a list of entities and return a partial HTML response of matching entities
|
||||
* to be used as a result preview suggestion list for global system searches.
|
||||
*/
|
||||
public function searchSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('term', '');
|
||||
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results'];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$entity->setAttribute('preview_content', '');
|
||||
}
|
||||
|
||||
return view('search.parts.entity-suggestion-list', [
|
||||
'entities' => $entities->slice(0, 5)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\TagRepo;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
|
@ -19,22 +20,25 @@ class TagController extends Controller
|
|||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$search = $request->get('search', '');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'tags')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'usages' => trans('entities.tags_usages'),
|
||||
]);
|
||||
|
||||
$nameFilter = $request->get('name', '');
|
||||
$tags = $this->tagRepo
|
||||
->queryWithTotals($search, $nameFilter)
|
||||
->queryWithTotals($listOptions, $nameFilter)
|
||||
->paginate(50)
|
||||
->appends(array_filter([
|
||||
'search' => $search,
|
||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
||||
'name' => $nameFilter,
|
||||
]));
|
||||
])));
|
||||
|
||||
$this->setPageTitle(trans('entities.tags'));
|
||||
|
||||
return view('tags.index', [
|
||||
'tags' => $tags,
|
||||
'search' => $search,
|
||||
'nameFilter' => $nameFilter,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
|
||||
use BookStack\Auth\Queries\UsersAllPaginatedAndSorted;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
@ -21,9 +21,6 @@ class UserController extends Controller
|
|||
protected UserRepo $userRepo;
|
||||
protected ImageRepo $imageRepo;
|
||||
|
||||
/**
|
||||
* UserController constructor.
|
||||
*/
|
||||
public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
|
@ -36,20 +33,23 @@ class UserController extends Controller
|
|||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission('users-manage');
|
||||
$listDetails = [
|
||||
'order' => $request->get('order', 'asc'),
|
||||
'search' => $request->get('search', ''),
|
||||
'sort' => $request->get('sort', 'name'),
|
||||
];
|
||||
|
||||
$users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'users')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'email' => trans('auth.email'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
'last_activity_at' => trans('settings.users_latest_activity'),
|
||||
]);
|
||||
|
||||
$users = (new UsersAllPaginatedAndSorted())->run(20, $listOptions);
|
||||
|
||||
$this->setPageTitle(trans('settings.users'));
|
||||
$users->appends($listDetails);
|
||||
$users->appends($listOptions->getPaginationAppends());
|
||||
|
||||
return view('users.index', [
|
||||
'users' => $users,
|
||||
'listDetails' => $listDetails,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -107,9 +107,8 @@ class UserController extends Controller
|
|||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$user->load(['apiTokens', 'mfaValues']);
|
||||
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
|
||||
|
||||
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
|
||||
|
@ -202,137 +201,4 @@ class UserController extends Controller
|
|||
|
||||
return redirect('/settings/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preferred book-list display setting.
|
||||
*/
|
||||
public function switchBooksView(Request $request, int $id)
|
||||
{
|
||||
return $this->switchViewType($id, $request, 'books');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preferred shelf-list display setting.
|
||||
*/
|
||||
public function switchShelvesView(Request $request, int $id)
|
||||
{
|
||||
return $this->switchViewType($id, $request, 'bookshelves');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preferred shelf-view book list display setting.
|
||||
*/
|
||||
public function switchShelfView(Request $request, int $id)
|
||||
{
|
||||
return $this->switchViewType($id, $request, 'bookshelf');
|
||||
}
|
||||
|
||||
/**
|
||||
* For a type of list, switch with stored view type for a user.
|
||||
*/
|
||||
protected function switchViewType(int $userId, Request $request, string $listName)
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $userId);
|
||||
|
||||
$viewType = $request->get('view_type');
|
||||
if (!in_array($viewType, ['grid', 'list'])) {
|
||||
$viewType = 'list';
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$key = $listName . '_view_type';
|
||||
setting()->putUser($user, $key, $viewType);
|
||||
|
||||
return redirect()->back(302, [], "/settings/users/$userId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the stored sort type for a particular view.
|
||||
*/
|
||||
public function changeSort(Request $request, string $id, string $type)
|
||||
{
|
||||
$validSortTypes = ['books', 'bookshelves', 'shelf_books'];
|
||||
if (!in_array($type, $validSortTypes)) {
|
||||
return redirect()->back(500);
|
||||
}
|
||||
|
||||
return $this->changeListSort($id, $request, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dark mode for the current user.
|
||||
*/
|
||||
public function toggleDarkMode()
|
||||
{
|
||||
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
|
||||
setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stored section expansion preference for the given user.
|
||||
*/
|
||||
public function updateExpansionPreference(Request $request, string $id, string $key)
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
$keyWhitelist = ['home-details'];
|
||||
if (!in_array($key, $keyWhitelist)) {
|
||||
return response('Invalid key', 500);
|
||||
}
|
||||
|
||||
$newState = $request->get('expand', 'false');
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
setting()->putUser($user, 'section_expansion#' . $key, $newState);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
public function updateCodeLanguageFavourite(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'language' => ['required', 'string', 'max:20'],
|
||||
'active' => ['required', 'bool'],
|
||||
]);
|
||||
|
||||
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
|
||||
$currentFavorites = array_filter(explode(',', $currentFavoritesStr));
|
||||
|
||||
$isFav = in_array($validated['language'], $currentFavorites);
|
||||
if (!$isFav && $validated['active']) {
|
||||
$currentFavorites[] = $validated['language'];
|
||||
} elseif ($isFav && !$validated['active']) {
|
||||
$index = array_search($validated['language'], $currentFavorites);
|
||||
array_splice($currentFavorites, $index, 1);
|
||||
}
|
||||
|
||||
setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
|
||||
}
|
||||
|
||||
/**
|
||||
* Changed the stored preference for a list sort order.
|
||||
*/
|
||||
protected function changeListSort(int $userId, Request $request, string $listName)
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $userId);
|
||||
|
||||
$sort = $request->get('sort');
|
||||
if (!in_array($sort, ['name', 'created_at', 'updated_at', 'default'])) {
|
||||
$sort = 'name';
|
||||
}
|
||||
|
||||
$order = $request->get('order');
|
||||
if (!in_array($order, ['asc', 'desc'])) {
|
||||
$order = 'asc';
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$sortKey = $listName . '_sort';
|
||||
$orderKey = $listName . '_sort_order';
|
||||
setting()->putUser($user, $sortKey, $sort);
|
||||
setting()->putUser($user, $orderKey, $order);
|
||||
|
||||
return redirect()->back(302, [], "/settings/users/$userId");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Settings\UserShortcutMap;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserPreferencesController extends Controller
|
||||
{
|
||||
protected UserRepo $userRepo;
|
||||
|
||||
public function __construct(UserRepo $userRepo)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the user-specific interface shortcuts.
|
||||
*/
|
||||
public function showShortcuts()
|
||||
{
|
||||
$shortcuts = UserShortcutMap::fromUserPreferences();
|
||||
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
|
||||
|
||||
return view('users.preferences.shortcuts', [
|
||||
'shortcuts' => $shortcuts,
|
||||
'enabled' => $enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user-specific interface shortcuts.
|
||||
*/
|
||||
public function updateShortcuts(Request $request)
|
||||
{
|
||||
$enabled = $request->get('enabled') === 'true';
|
||||
$providedShortcuts = $request->get('shortcut', []);
|
||||
$shortcuts = new UserShortcutMap($providedShortcuts);
|
||||
|
||||
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
|
||||
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
|
||||
|
||||
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
|
||||
|
||||
return redirect('/preferences/shortcuts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preferred view format for a list view of the given type.
|
||||
*/
|
||||
public function changeView(Request $request, string $type)
|
||||
{
|
||||
$valueViewTypes = ['books', 'bookshelves', 'bookshelf'];
|
||||
if (!in_array($type, $valueViewTypes)) {
|
||||
return redirect()->back(500);
|
||||
}
|
||||
|
||||
$view = $request->get('view');
|
||||
if (!in_array($view, ['grid', 'list'])) {
|
||||
$view = 'list';
|
||||
}
|
||||
|
||||
$key = $type . '_view_type';
|
||||
setting()->putForCurrentUser($key, $view);
|
||||
|
||||
return redirect()->back(302, [], "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the stored sort type for a particular view.
|
||||
*/
|
||||
public function changeSort(Request $request, string $type)
|
||||
{
|
||||
$validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
|
||||
if (!in_array($type, $validSortTypes)) {
|
||||
return redirect()->back(500);
|
||||
}
|
||||
|
||||
$sort = substr($request->get('sort') ?: 'name', 0, 50);
|
||||
$order = $request->get('order') === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$sortKey = $type . '_sort';
|
||||
$orderKey = $type . '_sort_order';
|
||||
setting()->putForCurrentUser($sortKey, $sort);
|
||||
setting()->putForCurrentUser($orderKey, $order);
|
||||
|
||||
return redirect()->back(302, [], "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dark mode for the current user.
|
||||
*/
|
||||
public function toggleDarkMode()
|
||||
{
|
||||
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
|
||||
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stored section expansion preference for the given user.
|
||||
*/
|
||||
public function changeExpansion(Request $request, string $type)
|
||||
{
|
||||
$typeWhitelist = ['home-details'];
|
||||
if (!in_array($type, $typeWhitelist)) {
|
||||
return response('Invalid key', 500);
|
||||
}
|
||||
|
||||
$newState = $request->get('expand', 'false');
|
||||
setting()->putForCurrentUser('section_expansion#' . $type, $newState);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the favorite status for a code language.
|
||||
*/
|
||||
public function updateCodeLanguageFavourite(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'language' => ['required', 'string', 'max:20'],
|
||||
'active' => ['required', 'bool'],
|
||||
]);
|
||||
|
||||
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
|
||||
$currentFavorites = array_filter(explode(',', $currentFavoritesStr));
|
||||
|
||||
$isFav = in_array($validated['language'], $currentFavorites);
|
||||
if (!$isFav && $validated['active']) {
|
||||
$currentFavorites[] = $validated['language'];
|
||||
} elseif ($isFav && !$validated['active']) {
|
||||
$index = array_search($validated['language'], $currentFavorites);
|
||||
array_splice($currentFavorites, $index, 1);
|
||||
}
|
||||
|
||||
setting()->putForCurrentUser('code-language-favourites', implode(',', $currentFavorites));
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
|
@ -3,7 +3,9 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Queries\WebhooksAllPaginatedAndSorted;
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
|
@ -18,16 +20,25 @@ class WebhookController extends Controller
|
|||
/**
|
||||
* Show all webhooks configured in the system.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->orderBy('name', 'desc')
|
||||
->with('trackedEvents')
|
||||
->get();
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'webhooks')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'endpoint' => trans('settings.webhooks_endpoint'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
'active' => trans('common.status'),
|
||||
]);
|
||||
|
||||
$webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listOptions);
|
||||
$webhooks->appends($listOptions->getPaginationAppends());
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks'));
|
||||
|
||||
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
|
||||
return view('settings.webhooks.index', [
|
||||
'webhooks' => $webhooks,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -19,14 +19,6 @@ class RouteServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public const HOME = '/';
|
||||
|
||||
/**
|
||||
* This namespace is applied to the controller routes in your routes file.
|
||||
*
|
||||
* In addition, it is set as the URL generator's root namespace.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
|
||||
/**
|
||||
* Define your route model bindings, pattern filters, etc.
|
||||
*
|
||||
|
|
|
@ -50,7 +50,7 @@ class SearchRunner
|
|||
* The provided count is for each entity to search,
|
||||
* Total returned could be larger and not guaranteed.
|
||||
*
|
||||
* @return array{total: int, count: int, has_more: bool, results: Entity[]}
|
||||
* @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
|
||||
*/
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
|
||||
{
|
||||
|
|
|
@ -194,6 +194,8 @@ class SettingService
|
|||
|
||||
/**
|
||||
* Put a user-specific setting into the database.
|
||||
* Can only take string value types since this may use
|
||||
* the session which is less flexible to data types.
|
||||
*/
|
||||
public function putUser(User $user, string $key, string $value): bool
|
||||
{
|
||||
|
@ -206,6 +208,16 @@ class SettingService
|
|||
return $this->put($this->userKey($user->id, $key), $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a user-specific setting into the database for the current access user.
|
||||
* Can only take string value types since this may use
|
||||
* the session which is less flexible to data types.
|
||||
*/
|
||||
public function putForCurrentUser(string $key, string $value)
|
||||
{
|
||||
return $this->putUser(user(), $key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a setting key into a user-specific key.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Settings;
|
||||
|
||||
class UserShortcutMap
|
||||
{
|
||||
protected const DEFAULTS = [
|
||||
// Header actions
|
||||
"home_view" => "1",
|
||||
"shelves_view" => "2",
|
||||
"books_view" => "3",
|
||||
"settings_view" => "4",
|
||||
"favourites_view" => "5",
|
||||
"profile_view" => "6",
|
||||
"global_search" => "/",
|
||||
"logout" => "0",
|
||||
|
||||
// Common actions
|
||||
"edit" => "e",
|
||||
"new" => "n",
|
||||
"copy" => "c",
|
||||
"delete" => "d",
|
||||
"favourite" => "f",
|
||||
"export" => "x",
|
||||
"sort" => "s",
|
||||
"permissions" => "p",
|
||||
"move" => "m",
|
||||
"revisions" => "r",
|
||||
|
||||
// Navigation
|
||||
"next" => "ArrowRight",
|
||||
"previous" => "ArrowLeft",
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $mapping;
|
||||
|
||||
public function __construct(array $map)
|
||||
{
|
||||
$this->mapping = static::DEFAULTS;
|
||||
$this->merge($map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the given map into the current shortcut mapping.
|
||||
*/
|
||||
protected function merge(array $map): void
|
||||
{
|
||||
foreach ($map as $key => $value) {
|
||||
if (is_string($value) && isset($this->mapping[$key])) {
|
||||
$this->mapping[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shortcut defined for the given ID.
|
||||
*/
|
||||
public function getShortcut(string $id): string
|
||||
{
|
||||
return $this->mapping[$id] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this mapping to JSON.
|
||||
*/
|
||||
public function toJson(): string
|
||||
{
|
||||
return json_encode($this->mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from the current user's preferences.
|
||||
*/
|
||||
public static function fromUserPreferences(): self
|
||||
{
|
||||
$userKeyMap = setting()->getForCurrentUser('ui-shortcuts');
|
||||
return new self(json_decode($userKeyMap, true) ?: []);
|
||||
}
|
||||
}
|
|
@ -88,16 +88,17 @@ class ImageService
|
|||
protected function getStorageDiskName(string $imageType): string
|
||||
{
|
||||
$storageType = config('filesystems.images');
|
||||
$localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
|
||||
|
||||
// Ensure system images (App logo) are uploaded to a public space
|
||||
if ($imageType === 'system' && $storageType === 'local_secure') {
|
||||
$storageType = 'local';
|
||||
if ($imageType === 'system' && $localSecureInUse) {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// Rename local_secure options to get our image specific storage driver which
|
||||
// is scoped to the relevant image directories.
|
||||
if ($storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
|
||||
$storageType = 'local_secure_images';
|
||||
if ($localSecureInUse) {
|
||||
return 'local_secure_images';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Handled options commonly used for item lists within the system, providing a standard
|
||||
* model for handling and validating sort, order and search options.
|
||||
*/
|
||||
class SimpleListOptions
|
||||
{
|
||||
protected string $typeKey;
|
||||
protected string $sort;
|
||||
protected string $order;
|
||||
protected string $search;
|
||||
protected array $sortOptions = [];
|
||||
|
||||
public function __construct(string $typeKey, string $sort, string $order, string $search = '')
|
||||
{
|
||||
$this->typeKey = $typeKey;
|
||||
$this->sort = $sort;
|
||||
$this->order = $order;
|
||||
$this->search = $search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from the given request.
|
||||
* Takes the item type (plural) that's used as a key for storing sort preferences.
|
||||
*/
|
||||
public static function fromRequest(Request $request, string $typeKey, bool $sortDescDefault = false): self
|
||||
{
|
||||
$search = $request->get('search', '');
|
||||
$sort = setting()->getForCurrentUser($typeKey . '_sort', '');
|
||||
$order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc');
|
||||
|
||||
return new self($typeKey, $sort, $order, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the valid sort options for this set of list options.
|
||||
* Provided sort options must be an array, keyed by search properties
|
||||
* with values being user-visible option labels.
|
||||
* Returns current options for easy fluent usage during creation.
|
||||
*/
|
||||
public function withSortOptions(array $sortOptions): self
|
||||
{
|
||||
$this->sortOptions = array_merge($this->sortOptions, $sortOptions);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current order option.
|
||||
*/
|
||||
public function getOrder(): string
|
||||
{
|
||||
return strtolower($this->order) === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current sort option.
|
||||
*/
|
||||
public function getSort(): string
|
||||
{
|
||||
$default = array_key_first($this->sortOptions) ?? 'name';
|
||||
$sort = $this->sort ?: $default;
|
||||
|
||||
if (empty($this->sortOptions) || array_key_exists($sort, $this->sortOptions)) {
|
||||
return $sort;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set search term.
|
||||
*/
|
||||
public function getSearch(): string
|
||||
{
|
||||
return $this->search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to append for pagination.
|
||||
*/
|
||||
public function getPaginationAppends(): array
|
||||
{
|
||||
return ['search' => $this->search];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data required by the sort control view.
|
||||
*/
|
||||
public function getSortControlData(): array
|
||||
{
|
||||
return [
|
||||
'options' => $this->sortOptions,
|
||||
'order' => $this->getOrder(),
|
||||
'sort' => $this->getSort(),
|
||||
'type' => $this->typeKey,
|
||||
];
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -24,7 +24,7 @@ class Dropdown {
|
|||
|
||||
All usage of $refs, $manyRefs and $opts should be done at the top of the `setup` function so any requirements can be easily seen.
|
||||
|
||||
Once defined, the component has to be registered for use. This is done in the `resources/js/components/index.js` file. You'll need to import the component class then add it to `componentMapping` object, following the pattern of other components.
|
||||
Once defined, the component has to be registered for use. This is done in the `resources/js/components/index.js` file by defining an additional export, following the pattern of other components.
|
||||
|
||||
### Using a Component in HTML
|
||||
|
||||
|
@ -80,9 +80,9 @@ Will result with `this.$opts` being:
|
|||
}
|
||||
```
|
||||
|
||||
#### Component Properties
|
||||
#### Component Properties & Methods
|
||||
|
||||
A component has the below shown properties available for use. As mentioned above, most of these should be used within the `setup()` function to make the requirements/dependencies of the component clear.
|
||||
A component has the below shown properties & methods available for use. As mentioned above, most of these should be used within the `setup()` function to make the requirements/dependencies of the component clear.
|
||||
|
||||
```javascript
|
||||
// The root element that the compontent has been applied to.
|
||||
|
@ -98,6 +98,15 @@ this.$manyRefs
|
|||
|
||||
// Options defined for the compontent.
|
||||
this.$opts
|
||||
|
||||
// The registered name of the component, usually kebab-case.
|
||||
this.$name
|
||||
|
||||
// Emit a custom event from this component.
|
||||
// Will be bubbled up from the dom element this is registered on,
|
||||
// as a custom event with the name `<elementName>-<eventName>`,
|
||||
// with the provided data in the event detail.
|
||||
this.$emit(eventName, data = {})
|
||||
```
|
||||
|
||||
## Global JavaScript Helpers
|
||||
|
@ -132,7 +141,16 @@ window.trans_plural(translationString, count, replacements);
|
|||
|
||||
// Component System
|
||||
// Parse and initialise any components from the given root el down.
|
||||
window.components.init(rootEl);
|
||||
// Get the first active component of the given name
|
||||
window.components.first(name);
|
||||
window.$components.init(rootEl);
|
||||
// Register component models to be used by the component system.
|
||||
// Takes a mapping of classes/constructors keyed by component names.
|
||||
// Names will be converted to kebab-case.
|
||||
window.$components.register(mapping);
|
||||
// Get the first active component of the given name.
|
||||
window.$components.first(name);
|
||||
// Get all the active components of the given name.
|
||||
window.$components.get(name);
|
||||
// Get the first active component of the given name that's been
|
||||
// created on the given element.
|
||||
window.$components.firstOnElement(element, name);
|
||||
```
|
File diff suppressed because it is too large
Load Diff
|
@ -16,11 +16,11 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "0.14.42",
|
||||
"esbuild": "^0.15.12",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.52.1"
|
||||
"sass": "^1.55.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.11",
|
||||
|
|
|
@ -1,4 +1 @@
|
|||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>
|
||||
|
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 216 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm8 7H9c-.55 0-1-.45-1-1s.45-1 1-1h6c.55 0 1 .45 1 1s-.45 1-1 1zm1-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg>
|
After Width: | Height: | Size: 367 B |
|
@ -27,5 +27,8 @@ window.trans_choice = translator.getPlural.bind(translator);
|
|||
window.trans_plural = translator.parsePlural.bind(translator);
|
||||
|
||||
// Load Components
|
||||
import components from "./components"
|
||||
components();
|
||||
import * as components from "./services/components"
|
||||
import * as componentMap from "./components";
|
||||
components.register(componentMap);
|
||||
window.$components = components;
|
||||
components.init();
|
||||
|
|
|
@ -4,6 +4,7 @@ import Clipboard from "clipboard/dist/clipboard.min";
|
|||
// Modes
|
||||
import 'codemirror/mode/css/css';
|
||||
import 'codemirror/mode/clike/clike';
|
||||
import 'codemirror/mode/dart/dart';
|
||||
import 'codemirror/mode/diff/diff';
|
||||
import 'codemirror/mode/fortran/fortran';
|
||||
import 'codemirror/mode/go/go';
|
||||
|
@ -27,6 +28,7 @@ import 'codemirror/mode/rust/rust';
|
|||
import 'codemirror/mode/shell/shell';
|
||||
import 'codemirror/mode/sql/sql';
|
||||
import 'codemirror/mode/stex/stex';
|
||||
import 'codemirror/mode/swift/swift';
|
||||
import 'codemirror/mode/toml/toml';
|
||||
import 'codemirror/mode/vb/vb';
|
||||
import 'codemirror/mode/vbscript/vbscript';
|
||||
|
@ -49,6 +51,7 @@ const modeMap = {
|
|||
'c++': 'text/x-c++src',
|
||||
'c#': 'text/x-csharp',
|
||||
csharp: 'text/x-csharp',
|
||||
dart: 'application/dart',
|
||||
diff: 'diff',
|
||||
for: 'fortran',
|
||||
fortran: 'fortran',
|
||||
|
@ -91,11 +94,12 @@ const modeMap = {
|
|||
rs: 'rust',
|
||||
shell: 'shell',
|
||||
sh: 'shell',
|
||||
sql: 'text/x-sql',
|
||||
stext: 'text/x-stex',
|
||||
swift: 'text/x-swift',
|
||||
toml: 'toml',
|
||||
ts: 'text/typescript',
|
||||
typescript: 'text/typescript',
|
||||
sql: 'text/x-sql',
|
||||
vbs: 'vbscript',
|
||||
vbscript: 'vbscript',
|
||||
'vb.net': 'text/x-vb',
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import {onChildEvent} from "../services/dom";
|
||||
import {uniqueId} from "../services/util";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* AddRemoveRows
|
||||
* Allows easy row add/remove controls onto a table.
|
||||
* Needs a model row to use when adding a new row.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AddRemoveRows {
|
||||
export class AddRemoveRows extends Component {
|
||||
setup() {
|
||||
this.modelRow = this.$refs.model;
|
||||
this.addButton = this.$refs.add;
|
||||
|
@ -31,7 +31,7 @@ class AddRemoveRows {
|
|||
clone.classList.remove('hidden');
|
||||
this.setClonedInputNames(clone);
|
||||
this.modelRow.parentNode.insertBefore(clone, this.modelRow);
|
||||
window.components.init(clone);
|
||||
window.$components.init(clone);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -50,5 +50,3 @@ class AddRemoveRows {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AddRemoveRows;
|
|
@ -1,10 +1,7 @@
|
|||
/**
|
||||
* AjaxDelete
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
class AjaxDeleteRow {
|
||||
export class AjaxDeleteRow extends Component {
|
||||
setup() {
|
||||
this.row = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
|
@ -28,5 +25,3 @@ class AjaxDeleteRow {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AjaxDeleteRow;
|
|
@ -1,4 +1,5 @@
|
|||
import {onEnterPress, onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Ajax Form
|
||||
|
@ -8,10 +9,8 @@ import {onEnterPress, onSelect} from "../services/dom";
|
|||
*
|
||||
* Will handle a real form if that's what the component is added to
|
||||
* otherwise will act as a fake form element.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AjaxForm {
|
||||
export class AjaxForm extends Component {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.responseContainer = this.container;
|
||||
|
@ -72,11 +71,9 @@ class AjaxForm {
|
|||
this.responseContainer.innerHTML = err.data;
|
||||
}
|
||||
|
||||
window.components.init(this.responseContainer);
|
||||
window.$components.init(this.responseContainer);
|
||||
this.responseContainer.style.opacity = null;
|
||||
this.responseContainer.style.pointerEvents = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default AjaxForm;
|
|
@ -1,10 +1,11 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Attachments List
|
||||
* Adds '?open=true' query to file attachment links
|
||||
* when ctrl/cmd is pressed down.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AttachmentsList {
|
||||
export class AttachmentsList extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
|
@ -43,5 +44,3 @@ class AttachmentsList {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AttachmentsList;
|
|
@ -1,10 +1,7 @@
|
|||
/**
|
||||
* Attachments
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {showLoading} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
class Attachments {
|
||||
export class Attachments extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
|
@ -46,10 +43,12 @@ class Attachments {
|
|||
|
||||
reloadList() {
|
||||
this.stopEdit();
|
||||
this.mainTabs.components.tabs.show('items');
|
||||
/** @var {Tabs} */
|
||||
const tabs = window.$components.firstOnElement(this.mainTabs, 'tabs');
|
||||
tabs.show('items');
|
||||
window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
|
||||
this.list.innerHTML = resp.data;
|
||||
window.components.init(this.list);
|
||||
window.$components.init(this.list);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -66,7 +65,7 @@ class Attachments {
|
|||
showLoading(this.editContainer);
|
||||
const resp = await window.$http.get(`/attachments/edit/${id}`);
|
||||
this.editContainer.innerHTML = resp.data;
|
||||
window.components.init(this.editContainer);
|
||||
window.$components.init(this.editContainer);
|
||||
}
|
||||
|
||||
stopEdit() {
|
||||
|
@ -75,5 +74,3 @@ class Attachments {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default Attachments;
|
|
@ -0,0 +1,11 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
export class AutoSubmit extends Component {
|
||||
|
||||
setup() {
|
||||
this.form = this.$el;
|
||||
|
||||
this.form.submit();
|
||||
}
|
||||
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
import {escapeHtml} from "../services/util";
|
||||
import {onChildEvent} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
const ajaxCache = {};
|
||||
|
||||
/**
|
||||
* AutoSuggest
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AutoSuggest {
|
||||
export class AutoSuggest extends Component {
|
||||
setup() {
|
||||
this.parent = this.$el.parentElement;
|
||||
this.container = this.$el;
|
||||
|
@ -149,5 +149,3 @@ class AutoSuggest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoSuggest;
|
|
@ -1,34 +1,35 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class BackToTop {
|
||||
export class BackToTop extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
setup() {
|
||||
this.button = this.$el;
|
||||
this.targetElem = document.getElementById('header');
|
||||
this.showing = false;
|
||||
this.breakPoint = 1200;
|
||||
|
||||
if (document.body.classList.contains('flexbox')) {
|
||||
this.elem.style.display = 'none';
|
||||
this.button.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.elem.addEventListener('click', this.scrollToTop.bind(this));
|
||||
this.button.addEventListener('click', this.scrollToTop.bind(this));
|
||||
window.addEventListener('scroll', this.onPageScroll.bind(this));
|
||||
}
|
||||
|
||||
onPageScroll() {
|
||||
let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
|
||||
if (!this.showing && scrollTopPos > this.breakPoint) {
|
||||
this.elem.style.display = 'block';
|
||||
this.button.style.display = 'block';
|
||||
this.showing = true;
|
||||
setTimeout(() => {
|
||||
this.elem.style.opacity = 0.4;
|
||||
this.button.style.opacity = 0.4;
|
||||
}, 1);
|
||||
} else if (this.showing && scrollTopPos < this.breakPoint) {
|
||||
this.elem.style.opacity = 0;
|
||||
this.button.style.opacity = 0;
|
||||
this.showing = false;
|
||||
setTimeout(() => {
|
||||
this.elem.style.display = 'none';
|
||||
this.button.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
@ -55,5 +56,3 @@ class BackToTop {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default BackToTop;
|
|
@ -1,4 +1,6 @@
|
|||
import Sortable from "sortablejs";
|
||||
import {Component} from "./component";
|
||||
import {htmlToDom} from "../services/dom";
|
||||
|
||||
// Auto sort control
|
||||
const sortOperations = {
|
||||
|
@ -35,14 +37,14 @@ const sortOperations = {
|
|||
},
|
||||
};
|
||||
|
||||
class BookSort {
|
||||
export class BookSort extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.sortContainer = elem.querySelector('[book-sort-boxes]');
|
||||
this.input = elem.querySelector('[book-sort-input]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.sortContainer = this.$refs.sortContainer;
|
||||
this.input = this.$refs.input;
|
||||
|
||||
const initialSortBox = elem.querySelector('.sort-box');
|
||||
const initialSortBox = this.container.querySelector('.sort-box');
|
||||
this.setupBookSortable(initialSortBox);
|
||||
this.setupSortPresets();
|
||||
|
||||
|
@ -90,14 +92,12 @@ class BookSort {
|
|||
* @param {Object} entityInfo
|
||||
*/
|
||||
bookSelect(entityInfo) {
|
||||
const alreadyAdded = this.elem.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
|
||||
const alreadyAdded = this.container.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
|
||||
if (alreadyAdded) return;
|
||||
|
||||
const entitySortItemUrl = entityInfo.link + '/sort-item';
|
||||
window.$http.get(entitySortItemUrl).then(resp => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.innerHTML = resp.data;
|
||||
const newBookContainer = wrap.children[0];
|
||||
const newBookContainer = htmlToDom(resp.data);
|
||||
this.sortContainer.append(newBookContainer);
|
||||
this.setupBookSortable(newBookContainer);
|
||||
});
|
||||
|
@ -155,7 +155,7 @@ class BookSort {
|
|||
*/
|
||||
buildEntityMap() {
|
||||
const entityMap = [];
|
||||
const lists = this.elem.querySelectorAll('.sort-list');
|
||||
const lists = this.container.querySelectorAll('.sort-list');
|
||||
|
||||
for (let list of lists) {
|
||||
const bookId = list.closest('[data-type="book"]').getAttribute('data-id');
|
||||
|
@ -203,5 +203,3 @@ class BookSort {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default BookSort;
|
|
@ -1,9 +1,7 @@
|
|||
import {slideUp, slideDown} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* @extends {Component}
|
||||
*/
|
||||
class ChapterContents {
|
||||
export class ChapterContents extends Component {
|
||||
|
||||
setup() {
|
||||
this.list = this.$refs.list;
|
||||
|
@ -31,7 +29,4 @@ class ChapterContents {
|
|||
event.preventDefault();
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ChapterContents;
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import {onChildEvent, onEnterPress, onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Code Editor
|
||||
* @extends {Component}
|
||||
*/
|
||||
class CodeEditor {
|
||||
|
||||
export class CodeEditor extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$refs.container;
|
||||
|
@ -73,7 +71,7 @@ class CodeEditor {
|
|||
isFavorite ? this.favourites.add(language) : this.favourites.delete(language);
|
||||
button.setAttribute('data-favourite', isFavorite ? 'true' : 'false');
|
||||
|
||||
window.$http.patch('/settings/users/update-code-language-favourite', {
|
||||
window.$http.patch('/preferences/update-code-language-favourite', {
|
||||
language: language,
|
||||
active: isFavorite
|
||||
});
|
||||
|
@ -128,7 +126,7 @@ class CodeEditor {
|
|||
}
|
||||
|
||||
this.loadHistory();
|
||||
this.popup.components.popup.show(() => {
|
||||
this.getPopup().show(() => {
|
||||
Code.updateLayout(this.editor);
|
||||
this.editor.focus();
|
||||
}, () => {
|
||||
|
@ -137,10 +135,17 @@ class CodeEditor {
|
|||
}
|
||||
|
||||
hide() {
|
||||
this.popup.components.popup.hide();
|
||||
this.getPopup().hide();
|
||||
this.addHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return window.$components.firstOnElement(this.popup, 'popup');
|
||||
}
|
||||
|
||||
async updateEditorMode(language) {
|
||||
const Code = await window.importVersioned('code');
|
||||
Code.setMode(this.editor, language, this.editor.getValue());
|
||||
|
@ -185,5 +190,3 @@ class CodeEditor {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
|
@ -1,14 +1,16 @@
|
|||
class CodeHighlighter {
|
||||
import {Component} from "./component";
|
||||
|
||||
constructor(elem) {
|
||||
const codeBlocks = elem.querySelectorAll('pre');
|
||||
export class CodeHighlighter extends Component{
|
||||
|
||||
setup() {
|
||||
const container = this.$el;
|
||||
|
||||
const codeBlocks = container.querySelectorAll('pre');
|
||||
if (codeBlocks.length > 0) {
|
||||
window.importVersioned('code').then(Code => {
|
||||
Code.highlightWithin(elem);
|
||||
Code.highlightWithin(container);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeHighlighter;
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* A simple component to render a code editor within the textarea
|
||||
* this exists upon.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class CodeTextarea {
|
||||
import {Component} from "./component";
|
||||
|
||||
export class CodeTextarea extends Component {
|
||||
|
||||
async setup() {
|
||||
const mode = this.$opts.mode;
|
||||
|
@ -12,5 +13,3 @@ class CodeTextarea {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeTextarea;
|
|
@ -1,35 +1,37 @@
|
|||
import {slideDown, slideUp} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Collapsible
|
||||
* Provides some simple logic to allow collapsible sections.
|
||||
*/
|
||||
class Collapsible {
|
||||
export class Collapsible extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.trigger = elem.querySelector('[collapsible-trigger]');
|
||||
this.content = elem.querySelector('[collapsible-content]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.trigger = this.$refs.trigger;
|
||||
this.content = this.$refs.content;
|
||||
|
||||
if (!this.trigger) return;
|
||||
if (this.trigger) {
|
||||
this.trigger.addEventListener('click', this.toggle.bind(this));
|
||||
this.openIfContainsError();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.elem.classList.add('open');
|
||||
this.container.classList.add('open');
|
||||
this.trigger.setAttribute('aria-expanded', 'true');
|
||||
slideDown(this.content, 300);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.elem.classList.remove('open');
|
||||
this.container.classList.remove('open');
|
||||
this.trigger.setAttribute('aria-expanded', 'false');
|
||||
slideUp(this.content, 300);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.elem.classList.contains('open')) {
|
||||
if (this.container.classList.contains('open')) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
|
@ -44,5 +46,3 @@ class Collapsible {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default Collapsible;
|
|
@ -0,0 +1,58 @@
|
|||
export class Component {
|
||||
|
||||
/**
|
||||
* The registered name of the component.
|
||||
* @type {string}
|
||||
*/
|
||||
$name = '';
|
||||
|
||||
/**
|
||||
* The element that the component is registered upon.
|
||||
* @type {Element}
|
||||
*/
|
||||
$el = null;
|
||||
|
||||
/**
|
||||
* Mapping of referenced elements within the component.
|
||||
* @type {Object<string, Element>}
|
||||
*/
|
||||
$refs = {};
|
||||
|
||||
/**
|
||||
* Mapping of arrays of referenced elements within the component so multiple
|
||||
* references, sharing the same name, can be fetched.
|
||||
* @type {Object<string, Element[]>}
|
||||
*/
|
||||
$manyRefs = {};
|
||||
|
||||
/**
|
||||
* Options passed into this component.
|
||||
* @type {Object<String, String>}
|
||||
*/
|
||||
$opts = {};
|
||||
|
||||
/**
|
||||
* Component-specific setup methods.
|
||||
* Use this to assign local variables and run any initial setup or actions.
|
||||
*/
|
||||
setup() {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event from this component.
|
||||
* Will be bubbled up from the dom element this is registered on, as a custom event
|
||||
* with the name `<elementName>-<eventName>`, with the provided data in the event detail.
|
||||
* @param {String} eventName
|
||||
* @param {Object} data
|
||||
*/
|
||||
$emit(eventName, data = {}) {
|
||||
data.from = this;
|
||||
const componentName = this.$name;
|
||||
const event = new CustomEvent(`${componentName}-${eventName}`, {
|
||||
bubbles: true,
|
||||
detail: data
|
||||
});
|
||||
this.$el.dispatchEvent(event);
|
||||
}
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Custom equivalent of window.confirm() using our popup component.
|
||||
* Is promise based so can be used like so:
|
||||
* `const result = await dialog.show()`
|
||||
* @extends {Component}
|
||||
*/
|
||||
class ConfirmDialog {
|
||||
export class ConfirmDialog extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
|
@ -34,7 +34,7 @@ class ConfirmDialog {
|
|||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return this.container.components.popup;
|
||||
return window.$components.firstOnElement(this.container, 'popup');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -48,5 +48,3 @@ class ConfirmDialog {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
|
@ -1,18 +1,19 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class CustomCheckbox {
|
||||
export class CustomCheckbox extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.checkbox = elem.querySelector('input[type=checkbox]');
|
||||
this.display = elem.querySelector('[role="checkbox"]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.checkbox = this.container.querySelector('input[type=checkbox]');
|
||||
this.display = this.container.querySelector('[role="checkbox"]');
|
||||
|
||||
this.checkbox.addEventListener('change', this.stateChange.bind(this));
|
||||
this.elem.addEventListener('keydown', this.onKeyDown.bind(this));
|
||||
this.container.addEventListener('keydown', this.onKeyDown.bind(this));
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
const isEnterOrPress = event.keyCode === 32 || event.keyCode === 13;
|
||||
if (isEnterOrPress) {
|
||||
const isEnterOrSpace = event.key === ' ' || event.key === 'Enter';
|
||||
if (isEnterOrSpace) {
|
||||
event.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
|
@ -30,5 +31,3 @@ class CustomCheckbox {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default CustomCheckbox;
|
|
@ -1,21 +1,22 @@
|
|||
class DetailsHighlighter {
|
||||
import {Component} from "./component";
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
export class DetailsHighlighter extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.dealtWith = false;
|
||||
elem.addEventListener('toggle', this.onToggle.bind(this));
|
||||
|
||||
this.container.addEventListener('toggle', this.onToggle.bind(this));
|
||||
}
|
||||
|
||||
onToggle() {
|
||||
if (this.dealtWith) return;
|
||||
|
||||
if (this.elem.querySelector('pre')) {
|
||||
if (this.container.querySelector('pre')) {
|
||||
window.importVersioned('code').then(Code => {
|
||||
Code.highlightWithin(this.elem);
|
||||
Code.highlightWithin(this.container);
|
||||
});
|
||||
}
|
||||
this.dealtWith = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default DetailsHighlighter;
|
|
@ -1,7 +1,8 @@
|
|||
import {debounce} from "../services/util";
|
||||
import {transitionHeight} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
class DropdownSearch {
|
||||
export class DropdownSearch extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
@ -79,5 +80,3 @@ class DropdownSearch {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default DropdownSearch;
|
|
@ -1,11 +1,12 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Dropdown
|
||||
* Provides some simple logic to create simple dropdown menus.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class DropDown {
|
||||
export class Dropdown extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
|
@ -17,8 +18,9 @@ class DropDown {
|
|||
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
||||
this.body = document.body;
|
||||
this.showing = false;
|
||||
this.setupListeners();
|
||||
|
||||
this.hide = this.hide.bind(this);
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
show(event = null) {
|
||||
|
@ -52,7 +54,7 @@ class DropDown {
|
|||
}
|
||||
|
||||
// Set listener to hide on mouse leave or window click
|
||||
this.menu.addEventListener('mouseleave', this.hide.bind(this));
|
||||
this.menu.addEventListener('mouseleave', this.hide);
|
||||
window.addEventListener('click', event => {
|
||||
if (!this.menu.contains(event.target)) {
|
||||
this.hide();
|
||||
|
@ -74,7 +76,7 @@ class DropDown {
|
|||
}
|
||||
|
||||
hideAll() {
|
||||
for (let dropdown of window.components.dropdown) {
|
||||
for (let dropdown of window.$components.get('dropdown')) {
|
||||
dropdown.hide();
|
||||
}
|
||||
}
|
||||
|
@ -97,33 +99,25 @@ class DropDown {
|
|||
this.showing = false;
|
||||
}
|
||||
|
||||
getFocusable() {
|
||||
return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
|
||||
}
|
||||
|
||||
focusNext() {
|
||||
const focusable = this.getFocusable();
|
||||
const currentIndex = focusable.indexOf(document.activeElement);
|
||||
let newIndex = currentIndex + 1;
|
||||
if (newIndex >= focusable.length) {
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
focusable[newIndex].focus();
|
||||
}
|
||||
|
||||
focusPrevious() {
|
||||
const focusable = this.getFocusable();
|
||||
const currentIndex = focusable.indexOf(document.activeElement);
|
||||
let newIndex = currentIndex - 1;
|
||||
if (newIndex < 0) {
|
||||
newIndex = focusable.length - 1;
|
||||
}
|
||||
|
||||
focusable[newIndex].focus();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
const keyboardNavHandler = new KeyboardNavigationHandler(this.container, (event) => {
|
||||
this.hide();
|
||||
this.toggle.focus();
|
||||
if (!this.bubbleEscapes) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, (event) => {
|
||||
if (event.target.nodeName === 'INPUT') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.hide();
|
||||
});
|
||||
|
||||
if (this.moveMenu) {
|
||||
keyboardNavHandler.shareHandlingToEl(this.menu);
|
||||
}
|
||||
|
||||
// Hide menu on option click
|
||||
this.container.addEventListener('click', event => {
|
||||
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
|
||||
|
@ -136,41 +130,9 @@ class DropDown {
|
|||
event.stopPropagation();
|
||||
this.show(event);
|
||||
if (event instanceof KeyboardEvent) {
|
||||
this.focusNext();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
const keyboardNavigation = event => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
this.focusNext();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
this.focusPrevious();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'Escape') {
|
||||
this.hide();
|
||||
this.toggle.focus();
|
||||
if (!this.bubbleEscapes) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
this.container.addEventListener('keydown', keyboardNavigation);
|
||||
if (this.moveMenu) {
|
||||
this.menu.addEventListener('keydown', keyboardNavigation);
|
||||
}
|
||||
|
||||
// Hide menu on enter press or escape
|
||||
this.menu.addEventListener('keydown ', event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
keyboardNavHandler.focusNext();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DropDown;
|
|
@ -1,11 +1,8 @@
|
|||
import DropZoneLib from "dropzone";
|
||||
import {fadeOut} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Dropzone
|
||||
* @extends {Component}
|
||||
*/
|
||||
class Dropzone {
|
||||
export class Dropzone extends Component {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
|
@ -74,5 +71,3 @@ class Dropzone {
|
|||
this.dz.removeAllFiles(true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dropzone;
|
|
@ -1,51 +1,58 @@
|
|||
class EditorToolbox {
|
||||
import {Component} from "./component";
|
||||
|
||||
constructor(elem) {
|
||||
export class EditorToolbox extends Component {
|
||||
|
||||
setup() {
|
||||
// Elements
|
||||
this.elem = elem;
|
||||
this.buttons = elem.querySelectorAll('[toolbox-tab-button]');
|
||||
this.contentElements = elem.querySelectorAll('[toolbox-tab-content]');
|
||||
this.toggleButton = elem.querySelector('[toolbox-toggle]');
|
||||
this.container = this.$el;
|
||||
this.buttons = this.$manyRefs.tabButton;
|
||||
this.contentElements = this.$manyRefs.tabContent;
|
||||
this.toggleButton = this.$refs.toggle;
|
||||
|
||||
// Toolbox toggle button click
|
||||
this.toggleButton.addEventListener('click', this.toggle.bind(this));
|
||||
// Tab button click
|
||||
this.elem.addEventListener('click', event => {
|
||||
let button = event.target.closest('[toolbox-tab-button]');
|
||||
if (button === null) return;
|
||||
let name = button.getAttribute('toolbox-tab-button');
|
||||
this.setActiveTab(name, true);
|
||||
});
|
||||
this.setupListeners();
|
||||
|
||||
// Set the first tab as active on load
|
||||
this.setActiveTab(this.contentElements[0].getAttribute('toolbox-tab-content'));
|
||||
this.setActiveTab(this.contentElements[0].dataset.tabContent);
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
// Toolbox toggle button click
|
||||
this.toggleButton.addEventListener('click', () => this.toggle());
|
||||
// Tab button click
|
||||
this.container.addEventListener('click', event => {
|
||||
const button = event.target.closest('button');
|
||||
if (this.buttons.includes(button)) {
|
||||
const name = button.dataset.tab;
|
||||
this.setActiveTab(name, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.elem.classList.toggle('open');
|
||||
const expanded = this.elem.classList.contains('open') ? 'true' : 'false';
|
||||
this.container.classList.toggle('open');
|
||||
const expanded = this.container.classList.contains('open') ? 'true' : 'false';
|
||||
this.toggleButton.setAttribute('aria-expanded', expanded);
|
||||
}
|
||||
|
||||
setActiveTab(tabName, openToolbox = false) {
|
||||
|
||||
// Set button visibility
|
||||
for (let i = 0, len = this.buttons.length; i < len; i++) {
|
||||
this.buttons[i].classList.remove('active');
|
||||
let bName = this.buttons[i].getAttribute('toolbox-tab-button');
|
||||
if (bName === tabName) this.buttons[i].classList.add('active');
|
||||
}
|
||||
// Set content visibility
|
||||
for (let i = 0, len = this.contentElements.length; i < len; i++) {
|
||||
this.contentElements[i].style.display = 'none';
|
||||
let cName = this.contentElements[i].getAttribute('toolbox-tab-content');
|
||||
if (cName === tabName) this.contentElements[i].style.display = 'block';
|
||||
for (const button of this.buttons) {
|
||||
button.classList.remove('active');
|
||||
const bName = button.dataset.tab;
|
||||
if (bName === tabName) button.classList.add('active');
|
||||
}
|
||||
|
||||
if (openToolbox && !this.elem.classList.contains('open')) {
|
||||
// Set content visibility
|
||||
for (const contentEl of this.contentElements) {
|
||||
contentEl.style.display = 'none';
|
||||
const cName = contentEl.dataset.tabContent;
|
||||
if (cName === tabName) contentEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (openToolbox && !this.container.classList.contains('open')) {
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EditorToolbox;
|
|
@ -1,9 +1,7 @@
|
|||
/**
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {htmlToDom} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
class EntityPermissions {
|
||||
export class EntityPermissions extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
|
@ -62,7 +60,7 @@ class EntityPermissions {
|
|||
}
|
||||
|
||||
removeRowOnButtonClick(button) {
|
||||
const row = button.closest('.content-permissions-row');
|
||||
const row = button.closest('.item-list-row');
|
||||
const roleId = button.dataset.roleId;
|
||||
const roleName = button.dataset.roleName;
|
||||
|
||||
|
@ -75,5 +73,3 @@ class EntityPermissions {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default EntityPermissions;
|
|
@ -1,10 +1,7 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Class EntitySearch
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySearch {
|
||||
export class EntitySearch extends Component {
|
||||
setup() {
|
||||
this.entityId = this.$opts.entityId;
|
||||
this.entityType = this.$opts.entityType;
|
||||
|
@ -55,5 +52,3 @@ class EntitySearch {
|
|||
this.searchInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default EntitySearch;
|
|
@ -1,14 +1,10 @@
|
|||
/**
|
||||
* Entity Selector Popup
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySelectorPopup {
|
||||
import {Component} from "./component";
|
||||
|
||||
export class EntitySelectorPopup extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.container = this.$el;
|
||||
this.selectButton = this.$refs.select;
|
||||
|
||||
window.EntitySelectorPopup = this;
|
||||
this.selectorEl = this.$refs.selector;
|
||||
|
||||
this.callback = null;
|
||||
|
@ -21,16 +17,26 @@ class EntitySelectorPopup {
|
|||
|
||||
show(callback) {
|
||||
this.callback = callback;
|
||||
this.elem.components.popup.show();
|
||||
this.getPopup().show();
|
||||
this.getSelector().focusSearch();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.elem.components.popup.hide();
|
||||
this.getPopup().hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return window.$components.firstOnElement(this.container, 'popup');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {EntitySelector}
|
||||
*/
|
||||
getSelector() {
|
||||
return this.selectorEl.components['entity-selector'];
|
||||
return window.$components.firstOnElement(this.selectorEl, 'entity-selector');
|
||||
}
|
||||
|
||||
onSelectButtonClick() {
|
||||
|
@ -52,5 +58,3 @@ class EntitySelectorPopup {
|
|||
if (this.callback && entity) this.callback(entity);
|
||||
}
|
||||
}
|
||||
|
||||
export default EntitySelectorPopup;
|
|
@ -1,10 +1,10 @@
|
|||
import {onChildEvent} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Entity Selector
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySelector {
|
||||
export class EntitySelector extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
@ -115,7 +115,7 @@ class EntitySelector {
|
|||
}
|
||||
|
||||
searchUrl() {
|
||||
return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||
return `/search/entity-selector?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||
}
|
||||
|
||||
searchEntities(searchTerm) {
|
||||
|
@ -186,5 +186,3 @@ class EntitySelector {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default EntitySelector;
|
|
@ -1,4 +1,5 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* EventEmitSelect
|
||||
|
@ -10,10 +11,8 @@ import {onSelect} from "../services/dom";
|
|||
*
|
||||
* All options will be set as the "detail" of the event with
|
||||
* their values included.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EventEmitSelect {
|
||||
export class EventEmitSelect extends Component{
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.name = this.$opts.name;
|
||||
|
@ -25,5 +24,3 @@ class EventEmitSelect {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default EventEmitSelect;
|
|
@ -1,17 +1,15 @@
|
|||
import {slideUp, slideDown} from "../services/animations";
|
||||
import {Component} from "./component";
|
||||
|
||||
class ExpandToggle {
|
||||
export class ExpandToggle extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
|
||||
// Component state
|
||||
this.isOpen = elem.getAttribute('expand-toggle-is-open') === 'yes';
|
||||
this.updateEndpoint = elem.getAttribute('expand-toggle-update-endpoint');
|
||||
this.selector = elem.getAttribute('expand-toggle');
|
||||
setup(elem) {
|
||||
this.targetSelector = this.$opts.targetSelector;
|
||||
this.isOpen = this.$opts.isOpen === 'true';
|
||||
this.updateEndpoint = this.$opts.updateEndpoint;
|
||||
|
||||
// Listener setup
|
||||
elem.addEventListener('click', this.click.bind(this));
|
||||
this.$el.addEventListener('click', this.click.bind(this));
|
||||
}
|
||||
|
||||
open(elemToToggle) {
|
||||
|
@ -25,7 +23,7 @@ class ExpandToggle {
|
|||
click(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const matchingElems = document.querySelectorAll(this.selector);
|
||||
const matchingElems = document.querySelectorAll(this.targetSelector);
|
||||
for (let match of matchingElems) {
|
||||
this.isOpen ? this.close(match) : this.open(match);
|
||||
}
|
||||
|
@ -41,5 +39,3 @@ class ExpandToggle {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default ExpandToggle;
|
|
@ -0,0 +1,82 @@
|
|||
import {htmlToDom} from "../services/dom";
|
||||
import {debounce} from "../services/util";
|
||||
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Global (header) search box handling.
|
||||
* Mainly to show live results preview.
|
||||
*/
|
||||
export class GlobalSearch extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.input = this.$refs.input;
|
||||
this.suggestions = this.$refs.suggestions;
|
||||
this.suggestionResultsWrap = this.$refs.suggestionResults;
|
||||
this.loadingWrap = this.$refs.loading;
|
||||
this.button = this.$refs.button;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
const updateSuggestionsDebounced = debounce(this.updateSuggestions.bind(this), 200, false);
|
||||
|
||||
// Handle search input changes
|
||||
this.input.addEventListener('input', () => {
|
||||
const value = this.input.value;
|
||||
if (value.length > 0) {
|
||||
this.loadingWrap.style.display = 'block';
|
||||
this.suggestionResultsWrap.style.opacity = '0.5';
|
||||
updateSuggestionsDebounced(value);
|
||||
} else {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Allow double click to show auto-click suggestions
|
||||
this.input.addEventListener('dblclick', () => {
|
||||
this.input.setAttribute('autocomplete', 'on');
|
||||
this.button.focus();
|
||||
this.input.focus();
|
||||
});
|
||||
|
||||
new KeyboardNavigationHandler(this.container, () => {
|
||||
this.hideSuggestions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} search
|
||||
*/
|
||||
async updateSuggestions(search) {
|
||||
const {data: results} = await window.$http.get('/search/suggest', {term: search});
|
||||
if (!this.input.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultDom = htmlToDom(results);
|
||||
|
||||
this.suggestionResultsWrap.innerHTML = '';
|
||||
this.suggestionResultsWrap.style.opacity = '1';
|
||||
this.loadingWrap.style.display = 'none';
|
||||
this.suggestionResultsWrap.append(resultDom);
|
||||
if (!this.container.classList.contains('search-active')) {
|
||||
this.showSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
showSuggestions() {
|
||||
this.container.classList.add('search-active');
|
||||
window.requestAnimationFrame(() => {
|
||||
this.suggestions.classList.add('search-suggestions-animation');
|
||||
})
|
||||
}
|
||||
|
||||
hideSuggestions() {
|
||||
this.container.classList.remove('search-active');
|
||||
this.suggestions.classList.remove('search-suggestions-animation');
|
||||
this.suggestionResultsWrap.innerHTML = '';
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class HeaderMobileToggle {
|
||||
export class HeaderMobileToggle extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
@ -37,5 +38,3 @@ class HeaderMobileToggle {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default HeaderMobileToggle;
|
|
@ -1,13 +1,9 @@
|
|||
import {onChildEvent, onSelect, removeLoading, showLoading} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* ImageManager
|
||||
* @extends {Component}
|
||||
*/
|
||||
class ImageManager {
|
||||
export class ImageManager extends Component {
|
||||
|
||||
setup() {
|
||||
|
||||
// Options
|
||||
this.uploadedTo = this.$opts.uploadedTo;
|
||||
|
||||
|
@ -36,8 +32,6 @@ class ImageManager {
|
|||
this.resetState();
|
||||
|
||||
this.setupListeners();
|
||||
|
||||
window.ImageManager = this;
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
|
@ -100,7 +94,7 @@ class ImageManager {
|
|||
|
||||
this.callback = callback;
|
||||
this.type = type;
|
||||
this.popupEl.components.popup.show();
|
||||
this.getPopup().show();
|
||||
this.dropzoneContainer.classList.toggle('hidden', type !== 'gallery');
|
||||
|
||||
if (!this.hasData) {
|
||||
|
@ -110,7 +104,14 @@ class ImageManager {
|
|||
}
|
||||
|
||||
hide() {
|
||||
this.popupEl.components.popup.hide();
|
||||
this.getPopup().hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Popup}
|
||||
*/
|
||||
getPopup() {
|
||||
return window.$components.firstOnElement(this.popupEl, 'popup');
|
||||
}
|
||||
|
||||
async loadGallery() {
|
||||
|
@ -132,7 +133,7 @@ class ImageManager {
|
|||
addReturnedHtmlElementsToList(html) {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html;
|
||||
window.components.init(el);
|
||||
window.$components.init(el);
|
||||
for (const child of [...el.children]) {
|
||||
this.listContainer.appendChild(child);
|
||||
}
|
||||
|
@ -207,9 +208,7 @@ class ImageManager {
|
|||
const params = requestDelete ? {delete: true} : {};
|
||||
const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params);
|
||||
this.formContainer.innerHTML = formHtml;
|
||||
window.components.init(this.formContainer);
|
||||
window.$components.init(this.formContainer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ImageManager;
|
|
@ -1,21 +1,25 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class ImagePicker {
|
||||
export class ImagePicker extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.imageElem = elem.querySelector('img');
|
||||
this.imageInput = elem.querySelector('input[type=file]');
|
||||
this.resetInput = elem.querySelector('input[data-reset-input]');
|
||||
this.removeInput = elem.querySelector('input[data-remove-input]');
|
||||
setup() {
|
||||
this.imageElem = this.$refs.image;
|
||||
this.imageInput = this.$refs.imageInput;
|
||||
this.resetInput = this.$refs.resetInput;
|
||||
this.removeInput = this.$refs.removeInput;
|
||||
this.resetButton = this.$refs.resetButton;
|
||||
this.removeButton = this.$refs.removeButton || null;
|
||||
|
||||
this.defaultImage = elem.getAttribute('data-default-image');
|
||||
this.defaultImage = this.$opts.defaultImage;
|
||||
|
||||
const resetButton = elem.querySelector('button[data-action="reset-image"]');
|
||||
resetButton.addEventListener('click', this.reset.bind(this));
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
const removeButton = elem.querySelector('button[data-action="remove-image"]');
|
||||
if (removeButton) {
|
||||
removeButton.addEventListener('click', this.removeImage.bind(this));
|
||||
setupListeners() {
|
||||
this.resetButton.addEventListener('click', this.reset.bind(this));
|
||||
|
||||
if (this.removeButton) {
|
||||
this.removeButton.addEventListener('click', this.removeImage.bind(this));
|
||||
}
|
||||
|
||||
this.imageInput.addEventListener('change', this.fileInputChange.bind(this));
|
||||
|
@ -51,5 +55,3 @@ class ImagePicker {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default ImagePicker;
|
|
@ -1,276 +1,59 @@
|
|||
import addRemoveRows from "./add-remove-rows.js"
|
||||
import ajaxDeleteRow from "./ajax-delete-row.js"
|
||||
import ajaxForm from "./ajax-form.js"
|
||||
import attachments from "./attachments.js"
|
||||
import attachmentsList from "./attachments-list.js"
|
||||
import autoSuggest from "./auto-suggest.js"
|
||||
import backToTop from "./back-to-top.js"
|
||||
import bookSort from "./book-sort.js"
|
||||
import chapterContents from "./chapter-contents.js"
|
||||
import codeEditor from "./code-editor.js"
|
||||
import codeHighlighter from "./code-highlighter.js"
|
||||
import codeTextarea from "./code-textarea.js"
|
||||
import collapsible from "./collapsible.js"
|
||||
import confirmDialog from "./confirm-dialog"
|
||||
import customCheckbox from "./custom-checkbox.js"
|
||||
import detailsHighlighter from "./details-highlighter.js"
|
||||
import dropdown from "./dropdown.js"
|
||||
import dropdownSearch from "./dropdown-search.js"
|
||||
import dropzone from "./dropzone.js"
|
||||
import editorToolbox from "./editor-toolbox.js"
|
||||
import entityPermissions from "./entity-permissions";
|
||||
import entitySearch from "./entity-search.js"
|
||||
import entitySelector from "./entity-selector.js"
|
||||
import entitySelectorPopup from "./entity-selector-popup.js"
|
||||
import eventEmitSelect from "./event-emit-select.js"
|
||||
import expandToggle from "./expand-toggle.js"
|
||||
import headerMobileToggle from "./header-mobile-toggle.js"
|
||||
import homepageControl from "./homepage-control.js"
|
||||
import imageManager from "./image-manager.js"
|
||||
import imagePicker from "./image-picker.js"
|
||||
import listSortControl from "./list-sort-control.js"
|
||||
import markdownEditor from "./markdown-editor.js"
|
||||
import newUserPassword from "./new-user-password.js"
|
||||
import notification from "./notification.js"
|
||||
import optionalInput from "./optional-input.js"
|
||||
import pageComments from "./page-comments.js"
|
||||
import pageDisplay from "./page-display.js"
|
||||
import pageEditor from "./page-editor.js"
|
||||
import pagePicker from "./page-picker.js"
|
||||
import permissionsTable from "./permissions-table.js"
|
||||
import pointer from "./pointer.js";
|
||||
import popup from "./popup.js"
|
||||
import settingAppColorPicker from "./setting-app-color-picker.js"
|
||||
import settingColorPicker from "./setting-color-picker.js"
|
||||
import shelfSort from "./shelf-sort.js"
|
||||
import sidebar from "./sidebar.js"
|
||||
import sortableList from "./sortable-list.js"
|
||||
import submitOnChange from "./submit-on-change.js"
|
||||
import tabs from "./tabs.js"
|
||||
import tagManager from "./tag-manager.js"
|
||||
import templateManager from "./template-manager.js"
|
||||
import toggleSwitch from "./toggle-switch.js"
|
||||
import triLayout from "./tri-layout.js"
|
||||
import userSelect from "./user-select.js"
|
||||
import webhookEvents from "./webhook-events";
|
||||
import wysiwygEditor from "./wysiwyg-editor.js"
|
||||
|
||||
const componentMapping = {
|
||||
"add-remove-rows": addRemoveRows,
|
||||
"ajax-delete-row": ajaxDeleteRow,
|
||||
"ajax-form": ajaxForm,
|
||||
"attachments": attachments,
|
||||
"attachments-list": attachmentsList,
|
||||
"auto-suggest": autoSuggest,
|
||||
"back-to-top": backToTop,
|
||||
"book-sort": bookSort,
|
||||
"chapter-contents": chapterContents,
|
||||
"code-editor": codeEditor,
|
||||
"code-highlighter": codeHighlighter,
|
||||
"code-textarea": codeTextarea,
|
||||
"collapsible": collapsible,
|
||||
"confirm-dialog": confirmDialog,
|
||||
"custom-checkbox": customCheckbox,
|
||||
"details-highlighter": detailsHighlighter,
|
||||
"dropdown": dropdown,
|
||||
"dropdown-search": dropdownSearch,
|
||||
"dropzone": dropzone,
|
||||
"editor-toolbox": editorToolbox,
|
||||
"entity-permissions": entityPermissions,
|
||||
"entity-search": entitySearch,
|
||||
"entity-selector": entitySelector,
|
||||
"entity-selector-popup": entitySelectorPopup,
|
||||
"event-emit-select": eventEmitSelect,
|
||||
"expand-toggle": expandToggle,
|
||||
"header-mobile-toggle": headerMobileToggle,
|
||||
"homepage-control": homepageControl,
|
||||
"image-manager": imageManager,
|
||||
"image-picker": imagePicker,
|
||||
"list-sort-control": listSortControl,
|
||||
"markdown-editor": markdownEditor,
|
||||
"new-user-password": newUserPassword,
|
||||
"notification": notification,
|
||||
"optional-input": optionalInput,
|
||||
"page-comments": pageComments,
|
||||
"page-display": pageDisplay,
|
||||
"page-editor": pageEditor,
|
||||
"page-picker": pagePicker,
|
||||
"permissions-table": permissionsTable,
|
||||
"pointer": pointer,
|
||||
"popup": popup,
|
||||
"setting-app-color-picker": settingAppColorPicker,
|
||||
"setting-color-picker": settingColorPicker,
|
||||
"shelf-sort": shelfSort,
|
||||
"sidebar": sidebar,
|
||||
"sortable-list": sortableList,
|
||||
"submit-on-change": submitOnChange,
|
||||
"tabs": tabs,
|
||||
"tag-manager": tagManager,
|
||||
"template-manager": templateManager,
|
||||
"toggle-switch": toggleSwitch,
|
||||
"tri-layout": triLayout,
|
||||
"user-select": userSelect,
|
||||
"webhook-events": webhookEvents,
|
||||
"wysiwyg-editor": wysiwygEditor,
|
||||
};
|
||||
|
||||
window.components = {};
|
||||
|
||||
/**
|
||||
* Initialize components of the given name within the given element.
|
||||
* @param {String} componentName
|
||||
* @param {HTMLElement|Document} parentElement
|
||||
*/
|
||||
function searchForComponentInParent(componentName, parentElement) {
|
||||
const elems = parentElement.querySelectorAll(`[${componentName}]`);
|
||||
for (let j = 0, jLen = elems.length; j < jLen; j++) {
|
||||
initComponent(componentName, elems[j]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a component instance on the given dom element.
|
||||
* @param {String} name
|
||||
* @param {Element} element
|
||||
*/
|
||||
function initComponent(name, element) {
|
||||
const componentModel = componentMapping[name];
|
||||
if (componentModel === undefined) return;
|
||||
|
||||
// Create our component instance
|
||||
let instance;
|
||||
try {
|
||||
instance = new componentModel(element);
|
||||
instance.$el = element;
|
||||
const allRefs = parseRefs(name, element);
|
||||
instance.$refs = allRefs.refs;
|
||||
instance.$manyRefs = allRefs.manyRefs;
|
||||
instance.$opts = parseOpts(name, element);
|
||||
instance.$emit = (eventName, data = {}) => {
|
||||
data.from = instance;
|
||||
const event = new CustomEvent(`${name}-${eventName}`, {
|
||||
bubbles: true,
|
||||
detail: data
|
||||
});
|
||||
instance.$el.dispatchEvent(event);
|
||||
};
|
||||
if (typeof instance.setup === 'function') {
|
||||
instance.setup();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create component', e, name, element);
|
||||
}
|
||||
|
||||
|
||||
// Add to global listing
|
||||
if (typeof window.components[name] === "undefined") {
|
||||
window.components[name] = [];
|
||||
}
|
||||
window.components[name].push(instance);
|
||||
|
||||
// Add to element listing
|
||||
if (typeof element.components === 'undefined') {
|
||||
element.components = {};
|
||||
}
|
||||
element.components[name] = instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse out the element references within the given element
|
||||
* for the given component name.
|
||||
* @param {String} name
|
||||
* @param {Element} element
|
||||
*/
|
||||
function parseRefs(name, element) {
|
||||
const refs = {};
|
||||
const manyRefs = {};
|
||||
|
||||
const prefix = `${name}@`
|
||||
const selector = `[refs*="${prefix}"]`;
|
||||
const refElems = [...element.querySelectorAll(selector)];
|
||||
if (element.matches(selector)) {
|
||||
refElems.push(element);
|
||||
}
|
||||
|
||||
for (const el of refElems) {
|
||||
const refNames = el.getAttribute('refs')
|
||||
.split(' ')
|
||||
.filter(str => str.startsWith(prefix))
|
||||
.map(str => str.replace(prefix, ''))
|
||||
.map(kebabToCamel);
|
||||
for (const ref of refNames) {
|
||||
refs[ref] = el;
|
||||
if (typeof manyRefs[ref] === 'undefined') {
|
||||
manyRefs[ref] = [];
|
||||
}
|
||||
manyRefs[ref].push(el);
|
||||
}
|
||||
}
|
||||
return {refs, manyRefs};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse out the element component options.
|
||||
* @param {String} name
|
||||
* @param {Element} element
|
||||
* @return {Object<String, String>}
|
||||
*/
|
||||
function parseOpts(name, element) {
|
||||
const opts = {};
|
||||
const prefix = `option:${name}:`;
|
||||
for (const {name, value} of element.attributes) {
|
||||
if (name.startsWith(prefix)) {
|
||||
const optName = name.replace(prefix, '');
|
||||
opts[kebabToCamel(optName)] = value || '';
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a kebab-case string to camelCase
|
||||
* @param {String} kebab
|
||||
* @returns {string}
|
||||
*/
|
||||
function kebabToCamel(kebab) {
|
||||
const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
|
||||
const words = kebab.split('-');
|
||||
return words[0] + words.slice(1).map(ucFirst).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all components found within the given element.
|
||||
* @param parentElement
|
||||
*/
|
||||
function initAll(parentElement) {
|
||||
if (typeof parentElement === 'undefined') parentElement = document;
|
||||
|
||||
// Old attribute system
|
||||
for (const componentName of Object.keys(componentMapping)) {
|
||||
searchForComponentInParent(componentName, parentElement);
|
||||
}
|
||||
|
||||
// New component system
|
||||
const componentElems = parentElement.querySelectorAll(`[component],[components]`);
|
||||
|
||||
for (const el of componentElems) {
|
||||
const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
|
||||
for (const name of componentNames) {
|
||||
initComponent(name, el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.components.init = initAll;
|
||||
window.components.first = (name) => (window.components[name] || [null])[0];
|
||||
|
||||
export default initAll;
|
||||
|
||||
/**
|
||||
* @typedef Component
|
||||
* @property {HTMLElement} $el
|
||||
* @property {Object<String, HTMLElement>} $refs
|
||||
* @property {Object<String, HTMLElement[]>} $manyRefs
|
||||
* @property {Object<String, String>} $opts
|
||||
* @property {function(string, Object)} $emit
|
||||
*/
|
||||
export {AddRemoveRows} from "./add-remove-rows.js"
|
||||
export {AjaxDeleteRow} from "./ajax-delete-row.js"
|
||||
export {AjaxForm} from "./ajax-form.js"
|
||||
export {Attachments} from "./attachments.js"
|
||||
export {AttachmentsList} from "./attachments-list.js"
|
||||
export {AutoSuggest} from "./auto-suggest.js"
|
||||
export {AutoSubmit} from "./auto-submit.js"
|
||||
export {BackToTop} from "./back-to-top.js"
|
||||
export {BookSort} from "./book-sort.js"
|
||||
export {ChapterContents} from "./chapter-contents.js"
|
||||
export {CodeEditor} from "./code-editor.js"
|
||||
export {CodeHighlighter} from "./code-highlighter.js"
|
||||
export {CodeTextarea} from "./code-textarea.js"
|
||||
export {Collapsible} from "./collapsible.js"
|
||||
export {ConfirmDialog} from "./confirm-dialog"
|
||||
export {CustomCheckbox} from "./custom-checkbox.js"
|
||||
export {DetailsHighlighter} from "./details-highlighter.js"
|
||||
export {Dropdown} from "./dropdown.js"
|
||||
export {DropdownSearch} from "./dropdown-search.js"
|
||||
export {Dropzone} from "./dropzone.js"
|
||||
export {EditorToolbox} from "./editor-toolbox.js"
|
||||
export {EntityPermissions} from "./entity-permissions"
|
||||
export {EntitySearch} from "./entity-search.js"
|
||||
export {EntitySelector} from "./entity-selector.js"
|
||||
export {EntitySelectorPopup} from "./entity-selector-popup.js"
|
||||
export {EventEmitSelect} from "./event-emit-select.js"
|
||||
export {ExpandToggle} from "./expand-toggle.js"
|
||||
export {GlobalSearch} from "./global-search.js"
|
||||
export {HeaderMobileToggle} from "./header-mobile-toggle.js"
|
||||
export {ImageManager} from "./image-manager.js"
|
||||
export {ImagePicker} from "./image-picker.js"
|
||||
export {ListSortControl} from "./list-sort-control.js"
|
||||
export {MarkdownEditor} from "./markdown-editor.js"
|
||||
export {NewUserPassword} from "./new-user-password.js"
|
||||
export {Notification} from "./notification.js"
|
||||
export {OptionalInput} from "./optional-input.js"
|
||||
export {PageComments} from "./page-comments.js"
|
||||
export {PageDisplay} from "./page-display.js"
|
||||
export {PageEditor} from "./page-editor.js"
|
||||
export {PagePicker} from "./page-picker.js"
|
||||
export {PermissionsTable} from "./permissions-table.js"
|
||||
export {Pointer} from "./pointer.js"
|
||||
export {Popup} from "./popup.js"
|
||||
export {SettingAppColorPicker} from "./setting-app-color-picker.js"
|
||||
export {SettingColorPicker} from "./setting-color-picker.js"
|
||||
export {SettingHomepageControl} from "./setting-homepage-control.js"
|
||||
export {ShelfSort} from "./shelf-sort.js"
|
||||
export {Shortcuts} from "./shortcuts"
|
||||
export {ShortcutInput} from "./shortcut-input"
|
||||
export {SortableList} from "./sortable-list.js"
|
||||
export {SubmitOnChange} from "./submit-on-change.js"
|
||||
export {Tabs} from "./tabs.js"
|
||||
export {TagManager} from "./tag-manager.js"
|
||||
export {TemplateManager} from "./template-manager.js"
|
||||
export {ToggleSwitch} from "./toggle-switch.js"
|
||||
export {TriLayout} from "./tri-layout.js"
|
||||
export {UserSelect} from "./user-select.js"
|
||||
export {WebhookEvents} from "./webhook-events"
|
||||
export {WysiwygEditor} from "./wysiwyg-editor.js"
|
||||
|
|
|
@ -2,16 +2,22 @@
|
|||
* ListSortControl
|
||||
* Manages the logic for the control which provides list sorting options.
|
||||
*/
|
||||
class ListSortControl {
|
||||
import {Component} from "./component";
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.menu = elem.querySelector('ul');
|
||||
export class ListSortControl extends Component {
|
||||
|
||||
this.sortInput = elem.querySelector('[name="sort"]');
|
||||
this.orderInput = elem.querySelector('[name="order"]');
|
||||
this.form = elem.querySelector('form');
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.menu = this.$refs.menu;
|
||||
|
||||
this.sortInput = this.$refs.sort;
|
||||
this.orderInput = this.$refs.order;
|
||||
this.form = this.$refs.form;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.menu.addEventListener('click', event => {
|
||||
if (event.target.closest('[data-sort-value]') !== null) {
|
||||
this.sortOptionClick(event);
|
||||
|
@ -34,12 +40,9 @@ class ListSortControl {
|
|||
|
||||
sortDirectionClick(event) {
|
||||
const currentDir = this.orderInput.value;
|
||||
const newDir = (currentDir === 'asc') ? 'desc' : 'asc';
|
||||
this.orderInput.value = newDir;
|
||||
this.orderInput.value = (currentDir === 'asc') ? 'desc' : 'asc';
|
||||
event.preventDefault();
|
||||
this.form.submit();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ListSortControl;
|
|
@ -1,11 +1,8 @@
|
|||
import MarkdownIt from "markdown-it";
|
||||
import mdTasksLists from 'markdown-it-task-lists';
|
||||
import Clipboard from "../services/clipboard";
|
||||
import {debounce} from "../services/util";
|
||||
import {patchDomFromHtmlString} from "../services/vdom";
|
||||
import DrawIO from "../services/drawio";
|
||||
import {Component} from "./component";
|
||||
import {init as initEditor} from "../markdown/editor";
|
||||
|
||||
class MarkdownEditor {
|
||||
export class MarkdownEditor extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
@ -15,80 +12,59 @@ class MarkdownEditor {
|
|||
this.imageUploadErrorText = this.$opts.imageUploadErrorText;
|
||||
this.serverUploadLimitText = this.$opts.serverUploadLimitText;
|
||||
|
||||
this.markdown = new MarkdownIt({html: true});
|
||||
this.markdown.use(mdTasksLists, {label: true});
|
||||
this.display = this.$refs.display;
|
||||
this.input = this.$refs.input;
|
||||
this.divider = this.$refs.divider;
|
||||
this.displayWrap = this.$refs.displayWrap;
|
||||
|
||||
this.display = this.elem.querySelector('.markdown-display');
|
||||
const settingContainer = this.$refs.settingContainer;
|
||||
const settingInputs = settingContainer.querySelectorAll('input[type="checkbox"]');
|
||||
|
||||
this.displayStylesLoaded = false;
|
||||
this.input = this.elem.querySelector('textarea');
|
||||
|
||||
this.cm = null;
|
||||
this.Code = null;
|
||||
const cmLoadPromise = window.importVersioned('code').then(Code => {
|
||||
this.cm = Code.markdownEditor(this.input);
|
||||
this.Code = Code;
|
||||
return this.cm;
|
||||
});
|
||||
|
||||
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
|
||||
|
||||
const displayLoad = () => {
|
||||
this.displayDoc = this.display.contentDocument;
|
||||
this.init(cmLoadPromise);
|
||||
};
|
||||
|
||||
if (this.display.contentDocument.readyState === 'complete') {
|
||||
displayLoad();
|
||||
} else {
|
||||
this.display.addEventListener('load', displayLoad.bind(this));
|
||||
}
|
||||
|
||||
window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
|
||||
markdownIt: this.markdown,
|
||||
this.editor = null;
|
||||
initEditor({
|
||||
pageId: this.pageId,
|
||||
container: this.elem,
|
||||
displayEl: this.display,
|
||||
codeMirrorInstance: this.cm,
|
||||
inputEl: this.input,
|
||||
drawioUrl: this.getDrawioUrl(),
|
||||
settingInputs: Array.from(settingInputs),
|
||||
text: {
|
||||
serverUploadLimit: this.serverUploadLimitText,
|
||||
imageUploadError: this.imageUploadErrorText,
|
||||
},
|
||||
}).then(editor => {
|
||||
this.editor = editor;
|
||||
this.setupListeners();
|
||||
this.emitEditorEvents();
|
||||
this.scrollToTextIfNeeded();
|
||||
this.editor.actions.updateAndRender();
|
||||
});
|
||||
}
|
||||
|
||||
init(cmLoadPromise) {
|
||||
|
||||
let lastClick = 0;
|
||||
|
||||
// Prevent markdown display link click redirect
|
||||
this.displayDoc.addEventListener('click', event => {
|
||||
let isDblClick = Date.now() - lastClick < 300;
|
||||
|
||||
let link = event.target.closest('a');
|
||||
if (link !== null) {
|
||||
event.preventDefault();
|
||||
window.open(link.getAttribute('href'));
|
||||
return;
|
||||
}
|
||||
|
||||
let drawing = event.target.closest('[drawio-diagram]');
|
||||
if (drawing !== null && isDblClick) {
|
||||
this.actionEditDrawing(drawing);
|
||||
return;
|
||||
}
|
||||
|
||||
lastClick = Date.now();
|
||||
emitEditorEvents() {
|
||||
window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
|
||||
markdownIt: this.editor.markdown.getRenderer(),
|
||||
displayEl: this.display,
|
||||
codeMirrorInstance: this.editor.cm,
|
||||
});
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
|
||||
// Button actions
|
||||
this.elem.addEventListener('click', event => {
|
||||
let button = event.target.closest('button[data-action]');
|
||||
if (button === null) return;
|
||||
|
||||
let action = button.getAttribute('data-action');
|
||||
if (action === 'insertImage') this.actionInsertImage();
|
||||
if (action === 'insertLink') this.actionShowLinkSelector();
|
||||
const action = button.getAttribute('data-action');
|
||||
if (action === 'insertImage') this.editor.actions.insertImage();
|
||||
if (action === 'insertLink') this.editor.actions.showLinkSelector();
|
||||
if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
|
||||
this.actionShowImageManager();
|
||||
this.editor.actions.showImageManager();
|
||||
return;
|
||||
}
|
||||
if (action === 'insertDrawing') this.actionStartDrawing();
|
||||
if (action === 'fullscreen') this.actionFullScreen();
|
||||
if (action === 'insertDrawing') this.editor.actions.startDrawing();
|
||||
if (action === 'fullscreen') this.editor.actions.fullScreen();
|
||||
});
|
||||
|
||||
// Mobile section toggling
|
||||
|
@ -97,527 +73,68 @@ class MarkdownEditor {
|
|||
if (!toolbarLabel) return;
|
||||
|
||||
const currentActiveSections = this.elem.querySelectorAll('.markdown-editor-wrap');
|
||||
for (let activeElem of currentActiveSections) {
|
||||
for (const activeElem of currentActiveSections) {
|
||||
activeElem.classList.remove('active');
|
||||
}
|
||||
|
||||
toolbarLabel.closest('.markdown-editor-wrap').classList.add('active');
|
||||
});
|
||||
|
||||
cmLoadPromise.then(cm => {
|
||||
this.codeMirrorSetup(cm);
|
||||
|
||||
// Refresh CodeMirror on container resize
|
||||
const resizeDebounced = debounce(() => this.Code.updateLayout(cm), 100, false);
|
||||
const resizeDebounced = debounce(() => this.editor.cm.refresh(), 100, false);
|
||||
const observer = new ResizeObserver(resizeDebounced);
|
||||
observer.observe(this.elem);
|
||||
|
||||
this.handleDividerDrag();
|
||||
}
|
||||
|
||||
handleDividerDrag() {
|
||||
this.divider.addEventListener('pointerdown', event => {
|
||||
const wrapRect = this.elem.getBoundingClientRect();
|
||||
const moveListener = (event) => {
|
||||
const xRel = event.pageX - wrapRect.left;
|
||||
const xPct = Math.min(Math.max(20, Math.floor((xRel / wrapRect.width) * 100)), 80);
|
||||
this.displayWrap.style.flexBasis = `${100-xPct}%`;
|
||||
this.editor.settings.set('editorWidth', xPct);
|
||||
};
|
||||
const upListener = (event) => {
|
||||
window.removeEventListener('pointermove', moveListener);
|
||||
window.removeEventListener('pointerup', upListener);
|
||||
this.display.style.pointerEvents = null;
|
||||
document.body.style.userSelect = null;
|
||||
this.editor.cm.refresh();
|
||||
};
|
||||
|
||||
this.display.style.pointerEvents = 'none';
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('pointermove', moveListener);
|
||||
window.addEventListener('pointerup', upListener);
|
||||
});
|
||||
const widthSetting = this.editor.settings.get('editorWidth');
|
||||
if (widthSetting) {
|
||||
this.displayWrap.style.flexBasis = `${100-widthSetting}%`;
|
||||
}
|
||||
}
|
||||
|
||||
this.listenForBookStackEditorEvents();
|
||||
|
||||
// Scroll to text if needed.
|
||||
scrollToTextIfNeeded() {
|
||||
const queryParams = (new URL(window.location)).searchParams;
|
||||
const scrollText = queryParams.get('content-text');
|
||||
if (scrollText) {
|
||||
this.scrollToText(scrollText);
|
||||
this.editor.actions.scrollToText(scrollText);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the input content and render the display.
|
||||
updateAndRender() {
|
||||
const content = this.cm.getValue();
|
||||
this.input.value = content;
|
||||
|
||||
const html = this.markdown.render(content);
|
||||
window.$events.emit('editor-html-change', html);
|
||||
window.$events.emit('editor-markdown-change', content);
|
||||
|
||||
// Set body content
|
||||
const target = this.getDisplayTarget();
|
||||
this.displayDoc.body.className = 'page-content';
|
||||
patchDomFromHtmlString(target, html);
|
||||
|
||||
// Copy styles from page head and set custom styles for editor
|
||||
this.loadStylesIntoDisplay();
|
||||
}
|
||||
|
||||
getDisplayTarget() {
|
||||
const body = this.displayDoc.body;
|
||||
|
||||
if (body.children.length === 0) {
|
||||
const wrap = document.createElement('div');
|
||||
this.displayDoc.body.append(wrap);
|
||||
}
|
||||
|
||||
return body.children[0];
|
||||
}
|
||||
|
||||
loadStylesIntoDisplay() {
|
||||
if (this.displayStylesLoaded) return;
|
||||
this.displayDoc.documentElement.classList.add('markdown-editor-display');
|
||||
// Set display to be dark mode if parent is
|
||||
|
||||
if (document.documentElement.classList.contains('dark-mode')) {
|
||||
this.displayDoc.documentElement.style.backgroundColor = '#222';
|
||||
this.displayDoc.documentElement.classList.add('dark-mode');
|
||||
}
|
||||
|
||||
this.displayDoc.head.innerHTML = '';
|
||||
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
|
||||
for (let style of styles) {
|
||||
const copy = style.cloneNode(true);
|
||||
this.displayDoc.head.appendChild(copy);
|
||||
}
|
||||
|
||||
this.displayStylesLoaded = true;
|
||||
}
|
||||
|
||||
onMarkdownScroll(lineCount) {
|
||||
const elems = this.displayDoc.body.children;
|
||||
if (elems.length <= lineCount) return;
|
||||
|
||||
const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
|
||||
topElem.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth'});
|
||||
}
|
||||
|
||||
codeMirrorSetup(cm) {
|
||||
const context = this;
|
||||
|
||||
// Text direction
|
||||
// cm.setOption('direction', this.textDirection);
|
||||
cm.setOption('direction', 'ltr'); // Will force to remain as ltr for now due to issues when HTML is in editor.
|
||||
// Custom key commands
|
||||
let metaKey = this.Code.getMetaKey();
|
||||
const extraKeys = {};
|
||||
// Insert Image shortcut
|
||||
extraKeys[`${metaKey}-Alt-I`] = function(cm) {
|
||||
let selectedText = cm.getSelection();
|
||||
let newText = ``;
|
||||
let cursorPos = cm.getCursor('from');
|
||||
cm.replaceSelection(newText);
|
||||
cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
|
||||
};
|
||||
// Save draft
|
||||
extraKeys[`${metaKey}-S`] = cm => {window.$events.emit('editor-save-draft')};
|
||||
// Save page
|
||||
extraKeys[`${metaKey}-Enter`] = cm => {window.$events.emit('editor-save-page')};
|
||||
// Show link selector
|
||||
extraKeys[`Shift-${metaKey}-K`] = cm => {this.actionShowLinkSelector()};
|
||||
// Insert Link
|
||||
extraKeys[`${metaKey}-K`] = cm => {insertLink()};
|
||||
// FormatShortcuts
|
||||
extraKeys[`${metaKey}-1`] = cm => {replaceLineStart('##');};
|
||||
extraKeys[`${metaKey}-2`] = cm => {replaceLineStart('###');};
|
||||
extraKeys[`${metaKey}-3`] = cm => {replaceLineStart('####');};
|
||||
extraKeys[`${metaKey}-4`] = cm => {replaceLineStart('#####');};
|
||||
extraKeys[`${metaKey}-5`] = cm => {replaceLineStart('');};
|
||||
extraKeys[`${metaKey}-D`] = cm => {replaceLineStart('');};
|
||||
extraKeys[`${metaKey}-6`] = cm => {replaceLineStart('>');};
|
||||
extraKeys[`${metaKey}-Q`] = cm => {replaceLineStart('>');};
|
||||
extraKeys[`${metaKey}-7`] = cm => {wrapSelection('\n```\n', '\n```');};
|
||||
extraKeys[`${metaKey}-8`] = cm => {wrapSelection('`', '`');};
|
||||
extraKeys[`Shift-${metaKey}-E`] = cm => {wrapSelection('`', '`');};
|
||||
extraKeys[`${metaKey}-9`] = cm => {wrapSelection('<p class="callout info">', '</p>');};
|
||||
extraKeys[`${metaKey}-P`] = cm => {replaceLineStart('-')}
|
||||
extraKeys[`${metaKey}-O`] = cm => {replaceLineStartForOrderedList()}
|
||||
cm.setOption('extraKeys', extraKeys);
|
||||
|
||||
// Update data on content change
|
||||
cm.on('change', (instance, changeObj) => {
|
||||
this.updateAndRender();
|
||||
});
|
||||
|
||||
const onScrollDebounced = debounce((instance) => {
|
||||
// Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
|
||||
let scroll = instance.getScrollInfo();
|
||||
let atEnd = scroll.top + scroll.clientHeight === scroll.height;
|
||||
if (atEnd) {
|
||||
this.onMarkdownScroll(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
let lineNum = instance.lineAtHeight(scroll.top, 'local');
|
||||
let range = instance.getRange({line: 0, ch: null}, {line: lineNum, ch: null});
|
||||
let parser = new DOMParser();
|
||||
let doc = parser.parseFromString(this.markdown.render(range), 'text/html');
|
||||
let totalLines = doc.documentElement.querySelectorAll('body > *');
|
||||
this.onMarkdownScroll(totalLines.length);
|
||||
}, 100);
|
||||
|
||||
// Handle scroll to sync display view
|
||||
cm.on('scroll', instance => {
|
||||
onScrollDebounced(instance);
|
||||
});
|
||||
|
||||
// Handle image paste
|
||||
cm.on('paste', (cm, event) => {
|
||||
const clipboard = new Clipboard(event.clipboardData || event.dataTransfer);
|
||||
|
||||
// Don't handle the event ourselves if no items exist of contains table-looking data
|
||||
if (!clipboard.hasItems() || clipboard.containsTabularData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const images = clipboard.getImages();
|
||||
for (const image of images) {
|
||||
uploadImage(image);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle image & content drag n drop
|
||||
cm.on('drop', (cm, event) => {
|
||||
|
||||
const templateId = event.dataTransfer.getData('bookstack/template');
|
||||
if (templateId) {
|
||||
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
|
||||
cm.setCursor(cursorPos);
|
||||
event.preventDefault();
|
||||
window.$http.get(`/templates/${templateId}`).then(resp => {
|
||||
const content = resp.data.markdown || resp.data.html;
|
||||
cm.replaceSelection(content);
|
||||
});
|
||||
}
|
||||
|
||||
const clipboard = new Clipboard(event.dataTransfer);
|
||||
if (clipboard.hasItems() && clipboard.getImages().length > 0) {
|
||||
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
|
||||
cm.setCursor(cursorPos);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const images = clipboard.getImages();
|
||||
for (const image of images) {
|
||||
uploadImage(image);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Helper to replace editor content
|
||||
function replaceContent(search, replace) {
|
||||
let text = cm.getValue();
|
||||
let cursor = cm.listSelections();
|
||||
cm.setValue(text.replace(search, replace));
|
||||
cm.setSelections(cursor);
|
||||
}
|
||||
|
||||
// Helper to replace the start of the line
|
||||
function replaceLineStart(newStart) {
|
||||
let cursor = cm.getCursor();
|
||||
let lineContent = cm.getLine(cursor.line);
|
||||
let lineLen = lineContent.length;
|
||||
let lineStart = lineContent.split(' ')[0];
|
||||
|
||||
// Remove symbol if already set
|
||||
if (lineStart === newStart) {
|
||||
lineContent = lineContent.replace(`${newStart} `, '');
|
||||
cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
|
||||
cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
|
||||
return;
|
||||
}
|
||||
|
||||
let alreadySymbol = /^[#>`]/.test(lineStart);
|
||||
let posDif = 0;
|
||||
if (alreadySymbol) {
|
||||
posDif = newStart.length - lineStart.length;
|
||||
lineContent = lineContent.replace(lineStart, newStart).trim();
|
||||
} else if (newStart !== '') {
|
||||
posDif = newStart.length + 1;
|
||||
lineContent = newStart + ' ' + lineContent;
|
||||
}
|
||||
cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
|
||||
cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
|
||||
}
|
||||
|
||||
function wrapLine(start, end) {
|
||||
let cursor = cm.getCursor();
|
||||
let lineContent = cm.getLine(cursor.line);
|
||||
let lineLen = lineContent.length;
|
||||
let newLineContent = lineContent;
|
||||
|
||||
if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
|
||||
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
|
||||
} else {
|
||||
newLineContent = `${start}${lineContent}${end}`;
|
||||
}
|
||||
|
||||
cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
|
||||
cm.setCursor({line: cursor.line, ch: cursor.ch + start.length});
|
||||
}
|
||||
|
||||
function wrapSelection(start, end) {
|
||||
let selection = cm.getSelection();
|
||||
if (selection === '') return wrapLine(start, end);
|
||||
|
||||
let newSelection = selection;
|
||||
let frontDiff = 0;
|
||||
let endDiff = 0;
|
||||
|
||||
if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
|
||||
newSelection = selection.slice(start.length, selection.length - end.length);
|
||||
endDiff = -(end.length + start.length);
|
||||
} else {
|
||||
newSelection = `${start}${selection}${end}`;
|
||||
endDiff = start.length + end.length;
|
||||
}
|
||||
|
||||
let selections = cm.listSelections()[0];
|
||||
cm.replaceSelection(newSelection);
|
||||
let headFirst = selections.head.ch <= selections.anchor.ch;
|
||||
selections.head.ch += headFirst ? frontDiff : endDiff;
|
||||
selections.anchor.ch += headFirst ? endDiff : frontDiff;
|
||||
cm.setSelections([selections]);
|
||||
}
|
||||
|
||||
function replaceLineStartForOrderedList() {
|
||||
const cursor = cm.getCursor();
|
||||
const prevLineContent = cm.getLine(cursor.line - 1) || '';
|
||||
const listMatch = prevLineContent.match(/^(\s*)(\d)([).])\s/) || [];
|
||||
|
||||
const number = (Number(listMatch[2]) || 0) + 1;
|
||||
const whiteSpace = listMatch[1] || '';
|
||||
const listMark = listMatch[3] || '.'
|
||||
|
||||
const prefix = `${whiteSpace}${number}${listMark}`;
|
||||
return replaceLineStart(prefix);
|
||||
}
|
||||
|
||||
// Handle image upload and add image into markdown content
|
||||
function uploadImage(file) {
|
||||
if (file === null || file.type.indexOf('image') !== 0) return;
|
||||
let ext = 'png';
|
||||
|
||||
if (file.name) {
|
||||
let fileNameMatches = file.name.match(/\.(.+)$/);
|
||||
if (fileNameMatches.length > 1) ext = fileNameMatches[1];
|
||||
}
|
||||
|
||||
// Insert image into markdown
|
||||
const id = "image-" + Math.random().toString(16).slice(2);
|
||||
const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
|
||||
const selectedText = cm.getSelection();
|
||||
const placeHolderText = ``;
|
||||
const cursor = cm.getCursor();
|
||||
cm.replaceSelection(placeHolderText);
|
||||
cm.setCursor({line: cursor.line, ch: cursor.ch + selectedText.length + 3});
|
||||
|
||||
const remoteFilename = "image-" + Date.now() + "." + ext;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, remoteFilename);
|
||||
formData.append('uploaded_to', context.pageId);
|
||||
|
||||
window.$http.post('/images/gallery', formData).then(resp => {
|
||||
const newContent = `[](${resp.data.url})`;
|
||||
replaceContent(placeHolderText, newContent);
|
||||
}).catch(err => {
|
||||
window.$events.emit('error', context.imageUploadErrorText);
|
||||
replaceContent(placeHolderText, selectedText);
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
let cursorPos = cm.getCursor('from');
|
||||
let selectedText = cm.getSelection() || '';
|
||||
let newText = `[${selectedText}]()`;
|
||||
cm.focus();
|
||||
cm.replaceSelection(newText);
|
||||
let cursorPosDiff = (selectedText === '') ? -3 : -1;
|
||||
cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
|
||||
}
|
||||
|
||||
this.updateAndRender();
|
||||
}
|
||||
|
||||
actionInsertImage() {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
window.ImageManager.show(image => {
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let selectedText = this.cm.getSelection();
|
||||
let newText = "[](" + image.url + ")";
|
||||
this.cm.focus();
|
||||
this.cm.replaceSelection(newText);
|
||||
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
|
||||
}, 'gallery');
|
||||
}
|
||||
|
||||
actionShowImageManager() {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
window.ImageManager.show(image => {
|
||||
this.insertDrawing(image, cursorPos);
|
||||
}, 'drawio');
|
||||
}
|
||||
|
||||
// Show the popup link selector and insert a link when finished
|
||||
actionShowLinkSelector() {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
window.EntitySelectorPopup.show(entity => {
|
||||
let selectedText = this.cm.getSelection() || entity.name;
|
||||
let newText = `[${selectedText}](${entity.link})`;
|
||||
this.cm.focus();
|
||||
this.cm.replaceSelection(newText);
|
||||
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the configured drawio instance.
|
||||
* @returns {String}
|
||||
*/
|
||||
getDrawioUrl() {
|
||||
const drawioUrlElem = document.querySelector('[drawio-url]');
|
||||
return drawioUrlElem ? drawioUrlElem.getAttribute('drawio-url') : false;
|
||||
const drawioAttrEl = document.querySelector('[drawio-url]');
|
||||
if (!drawioAttrEl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Show draw.io if enabled and handle save.
|
||||
actionStartDrawing() {
|
||||
const url = this.getDrawioUrl();
|
||||
if (!url) return;
|
||||
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
|
||||
DrawIO.show(url,() => {
|
||||
return Promise.resolve('');
|
||||
}, (pngData) => {
|
||||
|
||||
const data = {
|
||||
image: pngData,
|
||||
uploaded_to: Number(this.pageId),
|
||||
};
|
||||
|
||||
window.$http.post("/images/drawio", data).then(resp => {
|
||||
this.insertDrawing(resp.data, cursorPos);
|
||||
DrawIO.close();
|
||||
}).catch(err => {
|
||||
this.handleDrawingUploadError(err);
|
||||
});
|
||||
});
|
||||
return drawioAttrEl.getAttribute('drawio-url') || '';
|
||||
}
|
||||
|
||||
insertDrawing(image, originalCursor) {
|
||||
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
|
||||
this.cm.focus();
|
||||
this.cm.replaceSelection(newText);
|
||||
this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
|
||||
}
|
||||
|
||||
// Show draw.io if enabled and handle save.
|
||||
actionEditDrawing(imgContainer) {
|
||||
const drawioUrl = this.getDrawioUrl();
|
||||
if (!drawioUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
const drawingId = imgContainer.getAttribute('drawio-diagram');
|
||||
|
||||
DrawIO.show(drawioUrl, () => {
|
||||
return DrawIO.load(drawingId);
|
||||
}, (pngData) => {
|
||||
|
||||
let data = {
|
||||
image: pngData,
|
||||
uploaded_to: Number(this.pageId),
|
||||
};
|
||||
|
||||
window.$http.post("/images/drawio", data).then(resp => {
|
||||
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
|
||||
let newContent = this.cm.getValue().split('\n').map(line => {
|
||||
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
|
||||
return newText;
|
||||
}
|
||||
return line;
|
||||
}).join('\n');
|
||||
this.cm.setValue(newContent);
|
||||
this.cm.setCursor(cursorPos);
|
||||
this.cm.focus();
|
||||
DrawIO.close();
|
||||
}).catch(err => {
|
||||
this.handleDrawingUploadError(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleDrawingUploadError(error) {
|
||||
if (error.status === 413) {
|
||||
window.$events.emit('error', this.serverUploadLimitText);
|
||||
} else {
|
||||
window.$events.emit('error', this.imageUploadErrorText);
|
||||
}
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
// Make the editor full screen
|
||||
actionFullScreen() {
|
||||
const alreadyFullscreen = this.elem.classList.contains('fullscreen');
|
||||
this.elem.classList.toggle('fullscreen', !alreadyFullscreen);
|
||||
document.body.classList.toggle('markdown-fullscreen', !alreadyFullscreen);
|
||||
}
|
||||
|
||||
// Scroll to a specified text
|
||||
scrollToText(searchText) {
|
||||
if (!searchText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.cm.getValue();
|
||||
const lines = content.split(/\r?\n/);
|
||||
let lineNumber = lines.findIndex(line => {
|
||||
return line && line.indexOf(searchText) !== -1;
|
||||
});
|
||||
|
||||
if (lineNumber === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cm.scrollIntoView({
|
||||
line: lineNumber,
|
||||
}, 200);
|
||||
this.cm.focus();
|
||||
// set the cursor location.
|
||||
this.cm.setCursor({
|
||||
line: lineNumber,
|
||||
char: lines[lineNumber].length
|
||||
})
|
||||
}
|
||||
|
||||
listenForBookStackEditorEvents() {
|
||||
|
||||
function getContentToInsert({html, markdown}) {
|
||||
return markdown || html;
|
||||
}
|
||||
|
||||
// Replace editor content
|
||||
window.$events.listen('editor::replace', (eventContent) => {
|
||||
const markdown = getContentToInsert(eventContent);
|
||||
this.cm.setValue(markdown);
|
||||
});
|
||||
|
||||
// Append editor content
|
||||
window.$events.listen('editor::append', (eventContent) => {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
const markdown = getContentToInsert(eventContent);
|
||||
const content = this.cm.getValue() + '\n' + markdown;
|
||||
this.cm.setValue(content);
|
||||
this.cm.setCursor(cursorPos.line, cursorPos.ch);
|
||||
});
|
||||
|
||||
// Prepend editor content
|
||||
window.$events.listen('editor::prepend', (eventContent) => {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
const markdown = getContentToInsert(eventContent);
|
||||
const content = markdown + '\n' + this.cm.getValue();
|
||||
this.cm.setValue(content);
|
||||
const prependLineCount = markdown.split('\n').length;
|
||||
this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
|
||||
});
|
||||
|
||||
// Insert editor content at the current location
|
||||
window.$events.listen('editor::insert', (eventContent) => {
|
||||
const markdown = getContentToInsert(eventContent);
|
||||
this.cm.replaceSelection(markdown);
|
||||
});
|
||||
|
||||
// Focus on editor
|
||||
window.$events.listen('editor::focus', () => {
|
||||
this.cm.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default MarkdownEditor ;
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class NewUserPassword {
|
||||
export class NewUserPassword extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.inviteOption = elem.querySelector('input[name=send_invite]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.inputContainer = this.$refs.inputContainer;
|
||||
this.inviteOption = this.container.querySelector('input[name=send_invite]');
|
||||
|
||||
if (this.inviteOption) {
|
||||
this.inviteOption.addEventListener('change', this.inviteOptionChange.bind(this));
|
||||
|
@ -13,16 +15,12 @@ class NewUserPassword {
|
|||
|
||||
inviteOptionChange() {
|
||||
const inviting = (this.inviteOption.value === 'true');
|
||||
const passwordBoxes = this.elem.querySelectorAll('input[type=password]');
|
||||
const passwordBoxes = this.container.querySelectorAll('input[type=password]');
|
||||
for (const input of passwordBoxes) {
|
||||
input.disabled = inviting;
|
||||
}
|
||||
const container = this.elem.querySelector('#password-input-container');
|
||||
if (container) {
|
||||
container.style.display = inviting ? 'none' : 'block';
|
||||
}
|
||||
|
||||
this.inputContainer.style.display = inviting ? 'none' : 'block';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default NewUserPassword;
|
|
@ -1,19 +1,21 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class Notification {
|
||||
export class Notification extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.type = elem.getAttribute('notification');
|
||||
this.textElem = elem.querySelector('span');
|
||||
this.autohide = this.elem.hasAttribute('data-autohide');
|
||||
this.elem.style.display = 'grid';
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.type = this.$opts.type;
|
||||
this.textElem = this.container.querySelector('span');
|
||||
this.autoHide = this.$opts.autoHide === 'true';
|
||||
this.initialShow = this.$opts.show === 'true'
|
||||
this.container.style.display = 'grid';
|
||||
|
||||
window.$events.listen(this.type, text => {
|
||||
this.show(text);
|
||||
});
|
||||
elem.addEventListener('click', this.hide.bind(this));
|
||||
this.container.addEventListener('click', this.hide.bind(this));
|
||||
|
||||
if (elem.hasAttribute('data-show')) {
|
||||
if (this.initialShow) {
|
||||
setTimeout(() => this.show(this.textElem.textContent), 100);
|
||||
}
|
||||
|
||||
|
@ -21,14 +23,14 @@ class Notification {
|
|||
}
|
||||
|
||||
show(textToShow = '') {
|
||||
this.elem.removeEventListener('transitionend', this.hideCleanup);
|
||||
this.container.removeEventListener('transitionend', this.hideCleanup);
|
||||
this.textElem.textContent = textToShow;
|
||||
this.elem.style.display = 'grid';
|
||||
this.container.style.display = 'grid';
|
||||
setTimeout(() => {
|
||||
this.elem.classList.add('showing');
|
||||
this.container.classList.add('showing');
|
||||
}, 1);
|
||||
|
||||
if (this.autohide) {
|
||||
if (this.autoHide) {
|
||||
const words = textToShow.split(' ').length;
|
||||
const timeToShow = Math.max(2000, 1000 + (250 * words));
|
||||
setTimeout(this.hide.bind(this), timeToShow);
|
||||
|
@ -36,15 +38,13 @@ class Notification {
|
|||
}
|
||||
|
||||
hide() {
|
||||
this.elem.classList.remove('showing');
|
||||
this.elem.addEventListener('transitionend', this.hideCleanup);
|
||||
this.container.classList.remove('showing');
|
||||
this.container.addEventListener('transitionend', this.hideCleanup);
|
||||
}
|
||||
|
||||
hideCleanup() {
|
||||
this.elem.style.display = 'none';
|
||||
this.elem.removeEventListener('transitionend', this.hideCleanup);
|
||||
this.container.style.display = 'none';
|
||||
this.container.removeEventListener('transitionend', this.hideCleanup);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Notification;
|
|
@ -1,6 +1,7 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
class OptionalInput {
|
||||
export class OptionalInput extends Component {
|
||||
setup() {
|
||||
this.removeButton = this.$refs.remove;
|
||||
this.showButton = this.$refs.show;
|
||||
|
@ -24,5 +25,3 @@ class OptionalInput {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default OptionalInput;
|
|
@ -1,9 +1,8 @@
|
|||
import {scrollAndHighlightElement} from "../services/util";
|
||||
import {Component} from "./component";
|
||||
import {htmlToDom} from "../services/dom";
|
||||
|
||||
/**
|
||||
* @extends {Component}
|
||||
*/
|
||||
class PageComments {
|
||||
export class PageComments extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
@ -90,7 +89,7 @@ class PageComments {
|
|||
newComment.innerHTML = resp.data;
|
||||
this.editingComment.innerHTML = newComment.children[0].innerHTML;
|
||||
window.$events.success(this.updatedText);
|
||||
window.components.init(this.editingComment);
|
||||
window.$components.init(this.editingComment);
|
||||
this.closeUpdateForm();
|
||||
this.editingComment = null;
|
||||
}).catch(window.$events.showValidationErrors).then(() => {
|
||||
|
@ -119,11 +118,9 @@ class PageComments {
|
|||
};
|
||||
this.showLoading(this.form);
|
||||
window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
|
||||
let newComment = document.createElement('div');
|
||||
newComment.innerHTML = resp.data;
|
||||
let newElem = newComment.children[0];
|
||||
const newElem = htmlToDom(resp.data);
|
||||
this.container.appendChild(newElem);
|
||||
window.components.init(newElem);
|
||||
window.$components.init(newElem);
|
||||
window.$events.success(this.createdText);
|
||||
this.resetForm();
|
||||
this.updateCount();
|
||||
|
@ -200,5 +197,3 @@ class PageComments {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default PageComments;
|
|
@ -1,11 +1,12 @@
|
|||
import * as DOM from "../services/dom";
|
||||
import {scrollAndHighlightElement} from "../services/util";
|
||||
import {Component} from "./component";
|
||||
|
||||
class PageDisplay {
|
||||
export class PageDisplay extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.pageId = elem.getAttribute('page-display');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.pageId = this.$opts.pageId;
|
||||
|
||||
window.importVersioned('code').then(Code => Code.highlight());
|
||||
this.setupNavHighlighting();
|
||||
|
@ -13,7 +14,7 @@ class PageDisplay {
|
|||
|
||||
// Check the hash on load
|
||||
if (window.location.hash) {
|
||||
let text = window.location.hash.replace(/\%20/g, ' ').substr(1);
|
||||
const text = window.location.hash.replace(/%20/g, ' ').substring(1);
|
||||
this.goToText(text);
|
||||
}
|
||||
|
||||
|
@ -22,7 +23,7 @@ class PageDisplay {
|
|||
if (sidebarPageNav) {
|
||||
DOM.onChildEvent(sidebarPageNav, 'a', 'click', (event, child) => {
|
||||
event.preventDefault();
|
||||
window.components['tri-layout'][0].showContent();
|
||||
window.$components.first('tri-layout').showContent();
|
||||
const contentId = child.getAttribute('href').substr(1);
|
||||
this.goToText(contentId);
|
||||
window.history.pushState(null, null, '#' + contentId);
|
||||
|
@ -49,17 +50,10 @@ class PageDisplay {
|
|||
}
|
||||
|
||||
setupNavHighlighting() {
|
||||
// Check if support is present for IntersectionObserver
|
||||
if (!('IntersectionObserver' in window) ||
|
||||
!('IntersectionObserverEntry' in window) ||
|
||||
!('intersectionRatio' in window.IntersectionObserverEntry.prototype)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pageNav = document.querySelector('.sidebar-page-nav');
|
||||
const pageNav = document.querySelector('.sidebar-page-nav');
|
||||
|
||||
// fetch all the headings.
|
||||
let headings = document.querySelector('.page-content').querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
const headings = document.querySelector('.page-content').querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
// if headings are present, add observers.
|
||||
if (headings.length > 0 && pageNav !== null) {
|
||||
addNavObserver(headings);
|
||||
|
@ -67,21 +61,21 @@ class PageDisplay {
|
|||
|
||||
function addNavObserver(headings) {
|
||||
// Setup the intersection observer.
|
||||
let intersectOpts = {
|
||||
const intersectOpts = {
|
||||
rootMargin: '0px 0px 0px 0px',
|
||||
threshold: 1.0
|
||||
};
|
||||
let pageNavObserver = new IntersectionObserver(headingVisibilityChange, intersectOpts);
|
||||
const pageNavObserver = new IntersectionObserver(headingVisibilityChange, intersectOpts);
|
||||
|
||||
// observe each heading
|
||||
for (let heading of headings) {
|
||||
for (const heading of headings) {
|
||||
pageNavObserver.observe(heading);
|
||||
}
|
||||
}
|
||||
|
||||
function headingVisibilityChange(entries, observer) {
|
||||
for (let entry of entries) {
|
||||
let isVisible = (entry.intersectionRatio === 1);
|
||||
for (const entry of entries) {
|
||||
const isVisible = (entry.intersectionRatio === 1);
|
||||
toggleAnchorHighlighting(entry.target.id, isVisible);
|
||||
}
|
||||
}
|
||||
|
@ -99,9 +93,7 @@ class PageDisplay {
|
|||
codeMirrors.forEach(cm => cm.CodeMirror && cm.CodeMirror.refresh());
|
||||
};
|
||||
|
||||
const details = [...this.elem.querySelectorAll('details')];
|
||||
const details = [...this.container.querySelectorAll('details')];
|
||||
details.forEach(detail => detail.addEventListener('toggle', onToggle));
|
||||
}
|
||||
}
|
||||
|
||||
export default PageDisplay;
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import * as Dates from "../services/dates";
|
||||
import {onSelect} from "../services/dom";
|
||||
import {debounce} from "../services/util";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Page Editor
|
||||
* @extends {Component}
|
||||
*/
|
||||
class PageEditor {
|
||||
export class PageEditor extends Component {
|
||||
setup() {
|
||||
// Options
|
||||
this.draftsEnabled = this.$opts.draftsEnabled === 'true';
|
||||
|
@ -69,7 +67,8 @@ class PageEditor {
|
|||
});
|
||||
|
||||
// Changelog controls
|
||||
this.changelogInput.addEventListener('change', this.updateChangelogDisplay.bind(this));
|
||||
const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false);
|
||||
this.changelogInput.addEventListener('input', updateChangelogDebounced);
|
||||
|
||||
// Draft Controls
|
||||
onSelect(this.saveDraftButton, this.saveDraft.bind(this));
|
||||
|
@ -199,7 +198,8 @@ class PageEditor {
|
|||
event.preventDefault();
|
||||
|
||||
const link = event.target.closest('a').href;
|
||||
const dialog = this.switchDialogContainer.components['confirm-dialog'];
|
||||
/** @var {ConfirmDialog} **/
|
||||
const dialog = window.$components.firstOnElement(this.switchDialogContainer, 'confirm-dialog');
|
||||
const [saved, confirmed] = await Promise.all([this.saveDraft(), dialog.show()]);
|
||||
|
||||
if (saved && confirmed) {
|
||||
|
@ -208,5 +208,3 @@ class PageEditor {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default PageEditor;
|
|
@ -1,14 +1,14 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class PagePicker {
|
||||
export class PagePicker extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.input = elem.querySelector('input');
|
||||
this.resetButton = elem.querySelector('[page-picker-reset]');
|
||||
this.selectButton = elem.querySelector('[page-picker-select]');
|
||||
this.display = elem.querySelector('[page-picker-display]');
|
||||
this.defaultDisplay = elem.querySelector('[page-picker-default]');
|
||||
this.buttonSep = elem.querySelector('span.sep');
|
||||
setup() {
|
||||
this.input = this.$refs.input;
|
||||
this.resetButton = this.$refs.resetButton;
|
||||
this.selectButton = this.$refs.selectButton;
|
||||
this.display = this.$refs.display;
|
||||
this.defaultDisplay = this.$refs.defaultDisplay;
|
||||
this.buttonSep = this.$refs.buttonSeperator;
|
||||
|
||||
this.value = this.input.value;
|
||||
this.setupListeners();
|
||||
|
@ -24,7 +24,9 @@ class PagePicker {
|
|||
}
|
||||
|
||||
showPopup() {
|
||||
window.EntitySelectorPopup.show(entity => {
|
||||
/** @type {EntitySelectorPopup} **/
|
||||
const selectorPopup = window.$components.first('entity-selector-popup');
|
||||
selectorPopup.show(entity => {
|
||||
this.setValue(entity.id, entity.name);
|
||||
});
|
||||
}
|
||||
|
@ -36,7 +38,7 @@ class PagePicker {
|
|||
}
|
||||
|
||||
controlView(name) {
|
||||
let hasValue = this.value && this.value !== 0;
|
||||
const hasValue = this.value && this.value !== 0;
|
||||
toggleElem(this.resetButton, hasValue);
|
||||
toggleElem(this.buttonSep, hasValue);
|
||||
toggleElem(this.defaultDisplay, !hasValue);
|
||||
|
@ -55,8 +57,5 @@ class PagePicker {
|
|||
}
|
||||
|
||||
function toggleElem(elem, show) {
|
||||
let display = (elem.tagName === 'BUTTON' || elem.tagName === 'SPAN') ? 'inline-block' : 'block';
|
||||
elem.style.display = show ? display : 'none';
|
||||
elem.style.display = show ? null : 'none';
|
||||
}
|
||||
|
||||
export default PagePicker;
|
|
@ -1,8 +1,11 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class PermissionsTable {
|
||||
export class PermissionsTable extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.cellSelector = this.$opts.cellSelector || 'td,th';
|
||||
this.rowSelector = this.$opts.rowSelector || 'tr';
|
||||
|
||||
// Handle toggle all event
|
||||
for (const toggleAllElem of (this.$manyRefs.toggleAll || [])) {
|
||||
|
@ -27,15 +30,15 @@ class PermissionsTable {
|
|||
|
||||
toggleRowClick(event) {
|
||||
event.preventDefault();
|
||||
this.toggleAllInElement(event.target.closest('tr'));
|
||||
this.toggleAllInElement(event.target.closest(this.rowSelector));
|
||||
}
|
||||
|
||||
toggleColumnClick(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const tableCell = event.target.closest('th,td');
|
||||
const tableCell = event.target.closest(this.cellSelector);
|
||||
const colIndex = Array.from(tableCell.parentElement.children).indexOf(tableCell);
|
||||
const tableRows = tableCell.closest('table').querySelectorAll('tr');
|
||||
const tableRows = this.container.querySelectorAll(this.rowSelector);
|
||||
const inputsToToggle = [];
|
||||
|
||||
for (let row of tableRows) {
|
||||
|
@ -61,5 +64,3 @@ class PermissionsTable {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default PermissionsTable;
|
|
@ -1,10 +1,9 @@
|
|||
import * as DOM from "../services/dom";
|
||||
import Clipboard from "clipboard/dist/clipboard.min";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* @extends Component
|
||||
*/
|
||||
class Pointer {
|
||||
|
||||
export class Pointer extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
|
@ -127,5 +126,3 @@ class Pointer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Pointer;
|
|
@ -1,13 +1,13 @@
|
|||
import {fadeIn, fadeOut} from "../services/animations";
|
||||
import {onSelect} from "../services/dom";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Popup window that will contain other content.
|
||||
* This component provides the show/hide functionality
|
||||
* with the ability for popup@hide child references to close this.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class Popup {
|
||||
export class Popup extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
|
@ -57,5 +57,3 @@ class Popup {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default Popup;
|
|
@ -1,23 +1,13 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class SettingAppColorPicker {
|
||||
export class SettingAppColorPicker extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.colorInput = elem.querySelector('input[type=color]');
|
||||
this.lightColorInput = elem.querySelector('input[name="setting-app-color-light"]');
|
||||
this.resetButton = elem.querySelector('[setting-app-color-picker-reset]');
|
||||
this.defaultButton = elem.querySelector('[setting-app-color-picker-default]');
|
||||
setup() {
|
||||
this.colorInput = this.$refs.input;
|
||||
this.lightColorInput = this.$refs.lightInput;
|
||||
|
||||
this.colorInput.addEventListener('change', this.updateColor.bind(this));
|
||||
this.colorInput.addEventListener('input', this.updateColor.bind(this));
|
||||
this.resetButton.addEventListener('click', event => {
|
||||
this.colorInput.value = this.colorInput.dataset.current;
|
||||
this.updateColor();
|
||||
});
|
||||
this.defaultButton.addEventListener('click', event => {
|
||||
this.colorInput.value = this.colorInput.dataset.default;
|
||||
this.updateColor();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -44,8 +34,8 @@ class SettingAppColorPicker {
|
|||
/**
|
||||
* Covert a hex color code to rgb components.
|
||||
* @attribution https://stackoverflow.com/a/5624139
|
||||
* @param hex
|
||||
* @returns {*}
|
||||
* @param {String} hex
|
||||
* @returns {{r: Number, g: Number, b: Number}}
|
||||
*/
|
||||
hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
|
@ -57,5 +47,3 @@ class SettingAppColorPicker {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default SettingAppColorPicker;
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class SettingColorPicker {
|
||||
export class SettingColorPicker extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.colorInput = elem.querySelector('input[type=color]');
|
||||
this.resetButton = elem.querySelector('[setting-color-picker-reset]');
|
||||
this.defaultButton = elem.querySelector('[setting-color-picker-default]');
|
||||
this.resetButton.addEventListener('click', event => {
|
||||
this.colorInput.value = this.colorInput.dataset.current;
|
||||
});
|
||||
this.defaultButton.addEventListener('click', event => {
|
||||
this.colorInput.value = this.colorInput.dataset.default;
|
||||
});
|
||||
setup() {
|
||||
this.colorInput = this.$refs.input;
|
||||
this.resetButton = this.$refs.resetButton;
|
||||
this.defaultButton = this.$refs.defaultButton;
|
||||
this.currentColor = this.$opts.current;
|
||||
this.defaultColor = this.$opts.default;
|
||||
|
||||
this.resetButton.addEventListener('click', () => this.setValue(this.currentColor));
|
||||
this.defaultButton.addEventListener('click', () => this.setValue(this.defaultColor));
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
this.colorInput.value = value;
|
||||
this.colorInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingColorPicker;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
class HomepageControl {
|
||||
export class SettingHomepageControl extends Component {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.typeControl = elem.querySelector('[name="setting-app-homepage-type"]');
|
||||
this.pagePickerContainer = elem.querySelector('[page-picker-container]');
|
||||
setup() {
|
||||
this.typeControl = this.$refs.typeControl;
|
||||
this.pagePickerContainer = this.$refs.pagePickerContainer;
|
||||
|
||||
this.typeControl.addEventListener('change', this.controlPagePickerVisibility.bind(this));
|
||||
this.controlPagePickerVisibility();
|
||||
|
@ -14,9 +14,4 @@ class HomepageControl {
|
|||
const showPagePicker = this.typeControl.value === 'page';
|
||||
this.pagePickerContainer.style.display = (showPagePicker ? 'block' : 'none');
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default HomepageControl;
|
|
@ -1,6 +1,7 @@
|
|||
import Sortable from "sortablejs";
|
||||
import {Component} from "./component";
|
||||
|
||||
class ShelfSort {
|
||||
export class ShelfSort extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
@ -15,7 +16,7 @@ class ShelfSort {
|
|||
|
||||
initSortable() {
|
||||
const scrollBoxes = this.elem.querySelectorAll('.scroll-box');
|
||||
for (let scrollBox of scrollBoxes) {
|
||||
for (const scrollBox of scrollBoxes) {
|
||||
new Sortable(scrollBox, {
|
||||
group: 'shelf-books',
|
||||
ghostClass: 'primary-background-light',
|
||||
|
@ -79,5 +80,3 @@ class ShelfSort {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default ShelfSort;
|
|
@ -0,0 +1,54 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* Keys to ignore when recording shortcuts.
|
||||
* @type {string[]}
|
||||
*/
|
||||
const ignoreKeys = ['Control', 'Alt', 'Shift', 'Meta', 'Super', ' ', '+', 'Tab', 'Escape'];
|
||||
|
||||
export class ShortcutInput extends Component {
|
||||
|
||||
setup() {
|
||||
this.input = this.$el;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.listenerRecordKey = this.listenerRecordKey.bind(this);
|
||||
|
||||
this.input.addEventListener('focus', () => {
|
||||
this.startListeningForInput();
|
||||
});
|
||||
|
||||
this.input.addEventListener('blur', () => {
|
||||
this.stopListeningForInput();
|
||||
})
|
||||
}
|
||||
|
||||
startListeningForInput() {
|
||||
this.input.addEventListener('keydown', this.listenerRecordKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
listenerRecordKey(event) {
|
||||
if (ignoreKeys.includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = [
|
||||
event.ctrlKey ? 'Ctrl' : '',
|
||||
event.metaKey ? 'Cmd' : '',
|
||||
event.key,
|
||||
];
|
||||
|
||||
this.input.value = keys.filter(s => Boolean(s)).join(' + ');
|
||||
}
|
||||
|
||||
stopListeningForInput() {
|
||||
this.input.removeEventListener('keydown', this.listenerRecordKey);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
import {Component} from "./component";
|
||||
|
||||
function reverseMap(map) {
|
||||
const reversed = {};
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
reversed[value] = key;
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
|
||||
export class Shortcuts extends Component {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.mapById = JSON.parse(this.$opts.keyMap);
|
||||
this.mapByShortcut = reverseMap(this.mapById);
|
||||
|
||||
this.hintsShowing = false;
|
||||
|
||||
this.hideHints = this.hideHints.bind(this);
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
window.addEventListener('keydown', event => {
|
||||
|
||||
if (event.target.closest('input, select, textarea')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleShortcutPress(event);
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', event => {
|
||||
if (event.key === '?') {
|
||||
this.hintsShowing ? this.hideHints() : this.showHints();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleShortcutPress(event) {
|
||||
|
||||
const keys = [
|
||||
event.ctrlKey ? 'Ctrl' : '',
|
||||
event.metaKey ? 'Cmd' : '',
|
||||
event.key,
|
||||
];
|
||||
|
||||
const combo = keys.filter(s => Boolean(s)).join(' + ');
|
||||
|
||||
const shortcutId = this.mapByShortcut[combo];
|
||||
if (shortcutId) {
|
||||
const wasHandled = this.runShortcut(shortcutId);
|
||||
if (wasHandled) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the given shortcut, and return a boolean to indicate if the event
|
||||
* was successfully handled by a shortcut action.
|
||||
* @param {String} id
|
||||
* @return {boolean}
|
||||
*/
|
||||
runShortcut(id) {
|
||||
const el = this.container.querySelector(`[data-shortcut="${id}"]`);
|
||||
if (!el) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.matches('input, textarea, select')) {
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (el.matches('a, button')) {
|
||||
el.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (el.matches('div[tabindex]')) {
|
||||
el.click();
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
showHints() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('shortcut-container');
|
||||
this.container.append(wrapper);
|
||||
|
||||
const shortcutEls = this.container.querySelectorAll('[data-shortcut]');
|
||||
const displayedIds = new Set();
|
||||
for (const shortcutEl of shortcutEls) {
|
||||
const id = shortcutEl.getAttribute('data-shortcut');
|
||||
if (displayedIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = this.mapById[id];
|
||||
this.showHintLabel(shortcutEl, key, wrapper);
|
||||
displayedIds.add(id);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', this.hideHints);
|
||||
window.addEventListener('focus', this.hideHints);
|
||||
window.addEventListener('blur', this.hideHints);
|
||||
window.addEventListener('click', this.hideHints);
|
||||
|
||||
this.hintsShowing = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} targetEl
|
||||
* @param {String} key
|
||||
* @param {Element} wrapper
|
||||
*/
|
||||
showHintLabel(targetEl, key, wrapper) {
|
||||
const targetBounds = targetEl.getBoundingClientRect();
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.classList.add('shortcut-hint');
|
||||
label.textContent = key;
|
||||
|
||||
const linkage = document.createElement('div');
|
||||
linkage.classList.add('shortcut-linkage');
|
||||
linkage.style.left = targetBounds.x + 'px';
|
||||
linkage.style.top = targetBounds.y + 'px';
|
||||
linkage.style.width = targetBounds.width + 'px';
|
||||
linkage.style.height = targetBounds.height + 'px';
|
||||
|
||||
wrapper.append(label, linkage);
|
||||
|
||||
const labelBounds = label.getBoundingClientRect();
|
||||
|
||||
label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 6))}px`;
|
||||
label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
|
||||
}
|
||||
|
||||
hideHints() {
|
||||
const wrapper = this.container.querySelector('.shortcut-container');
|
||||
wrapper.remove();
|
||||
|
||||
window.removeEventListener('scroll', this.hideHints);
|
||||
window.removeEventListener('focus', this.hideHints);
|
||||
window.removeEventListener('blur', this.hideHints);
|
||||
window.removeEventListener('click', this.hideHints);
|
||||
|
||||
this.hintsShowing = false;
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
|
||||
class Sidebar {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.toggleElem = elem.querySelector('.sidebar-toggle');
|
||||
this.toggleElem.addEventListener('click', this.toggle.bind(this));
|
||||
}
|
||||
|
||||
toggle(show = true) {
|
||||
this.elem.classList.toggle('open');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Sidebar;
|
|
@ -1,4 +1,5 @@
|
|||
import Sortable from "sortablejs";
|
||||
import {Component} from "./component";
|
||||
|
||||
/**
|
||||
* SortableList
|
||||
|
@ -6,10 +7,8 @@ import Sortable from "sortablejs";
|
|||
* Can have data set on the dragged items by setting a 'data-drag-content' attribute.
|
||||
* This attribute must contain JSON where the keys are content types and the values are
|
||||
* the data to set on the data-transfer.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class SortableList {
|
||||
export class SortableList extends Component {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.handleSelector = this.$opts.handleSelector;
|
||||
|
@ -35,5 +34,3 @@ class SortableList {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default SortableList;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue