Compare commits

..

55 Commits

Author SHA1 Message Date
Dan Brown
d34f837e19 Started work on details/summary blocks 2022-01-21 17:07:27 +00:00
Dan Brown
264966de02 Crawled forward slightly on table resizing 2022-01-21 12:16:05 +00:00
Dan Brown
8b4f112462 Improved iframe embed interaction within editor 2022-01-20 13:55:44 +00:00
Dan Brown
20f37292a1 Added support for iframe node blocks 2022-01-20 13:38:16 +00:00
Dan Brown
b1f5495a7f Shared link mark update logic with color controls 2022-01-19 23:54:59 +00:00
Dan Brown
bb12541179 Improved anchor updating/remove action
Now will update the link mark if you have a no-range selection on the
link.
2022-01-19 23:22:48 +00:00
Dan Brown
e3ead1c115 Added radio options for anchor target option 2022-01-19 22:14:09 +00:00
Dan Brown
9b4ea368dc Started on table editing/resizing 2022-01-19 16:46:45 +00:00
Dan Brown
4b08eef12c Added table creation and insertion 2022-01-19 15:22:10 +00:00
Dan Brown
b2283106fc Added source code view/set button 2022-01-19 11:31:02 +00:00
Dan Brown
7125530e55 Added image resizing via drag handles 2022-01-17 17:43:16 +00:00
Dan Brown
7622106665 Added jsdoc types for prosemirror
Also added link markdown handling when target is set.
2022-01-16 15:21:57 +00:00
Dan Brown
89194a3f85 Got link insert/editor working 2022-01-16 14:37:58 +00:00
Dan Brown
7703face52 Started menu dialog support 2022-01-14 20:56:05 +00:00
Dan Brown
c013d7e549 Added inline code and clear formatting 2022-01-14 18:27:37 +00:00
Dan Brown
07c8876e22 Imported marks from example schema for customization 2022-01-14 14:55:07 +00:00
Dan Brown
0dc64d22ef Added horizonal rule insert 2022-01-14 14:33:37 +00:00
Dan Brown
013943dcc5 Added list buttons 2022-01-14 13:14:25 +00:00
Dan Brown
dc1c9807ef Reorganised & aligned editor icons 2022-01-12 16:10:16 +00:00
Dan Brown
56d7864bdf Added bg-color mark, added color grid selectors 2022-01-12 15:33:59 +00:00
Dan Brown
1018b5627e Added text color mark 2022-01-12 11:02:28 +00:00
Dan Brown
717557df89 Rolled out text alignment to other block types
Completed off alignment types and markdown handling in the process.
2022-01-12 10:18:06 +00:00
Dan Brown
6744ab2ff9 Got alignment buttons barely working for paragraphs 2022-01-11 18:58:24 +00:00
Dan Brown
4e5153d372 Copied in default node types for control and future editing 2022-01-11 17:13:40 +00:00
Dan Brown
34db138a64 Split marks and nodes into their own files 2022-01-11 16:26:12 +00:00
Dan Brown
c3595b1807 Added strike, sup and sub marks 2022-01-11 16:00:57 +00:00
Dan Brown
a8f48185b5 Got underline working in editor
Major step, since this is the first inline HTML element which needed
advanced parsing out on the markdown side, since not commonmark
supported.
2022-01-10 13:38:32 +00:00
Dan Brown
9d7174557e Added in a custom menubar
This is a copy of the ProseMirror/prosemirror-menu repo files
which suggest working from a fork of this.

These changes include the ability to select callouts
from the menubar.
2022-01-09 16:37:16 +00:00
Dan Brown
47c3d4fc0f Fixed issue with new nodes being callouts 2022-01-07 21:56:04 +00:00
Dan Brown
81dfe9c345 Got callouts about working, simplified markdown setup 2022-01-07 21:22:07 +00:00
Dan Brown
0fb8ba00a5 Attempted adding tricky custom block
Attempted adding callouts, which have the challenge of being shown via
HTML within markdown content. Got stuck on parsing back to the state
from markdown.
2022-01-07 16:37:36 +00:00
Dan Brown
aa9fe9ca82 Added notes file 2022-01-07 13:36:53 +00:00
Dan Brown
27f9e8e4bd Started playing with prosemirror
- Got base setup together with WYSIWYG/Markdown switching, where HTML is
  the base content format.
- Added some testing routes/views for initial development.
- Added some dev npm tasks to support editor-specific actions.
2022-01-07 13:36:52 +00:00
Dan Brown
c3f7b39a0f Addressed phpstan cases 2022-01-07 13:04:49 +00:00
Dan Brown
ef11100863 Updated translator attribution before release v21.12.1 2022-01-06 12:20:13 +00:00
Dan Brown
1a26b47782 Applied latest styleCI changes 2022-01-06 12:18:11 +00:00
Dan Brown
cb0d674a71 Merge branch 'sort_changes'
Related to #3134
2022-01-06 12:03:15 +00:00
Dan Brown
4d094331cf New Crowdin updates (#3117)
* New translations auth.php (Bulgarian)

* New translations auth.php (Catalan)

* New translations auth.php (Czech)

* New translations auth.php (Danish)

* New translations auth.php (Hebrew)

* New translations auth.php (Swedish)

* New translations auth.php (Hungarian)

* New translations auth.php (Italian)

* New translations auth.php (Japanese)

* New translations auth.php (Korean)

* New translations auth.php (Lithuanian)

* New translations auth.php (Dutch)

* New translations auth.php (Polish)

* New translations auth.php (Russian)

* New translations auth.php (Slovak)

* New translations auth.php (Slovenian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Bosnian)

* New translations settings.php (Latvian)

* New translations settings.php (Estonian)

* New translations settings.php (Croatian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Persian)

* New translations settings.php (Indonesian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Dutch)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (German Informal)

* New translations settings.php (Spanish)

* New translations activities.php (Spanish)

* New translations auth.php (Spanish)

* New translations common.php (Spanish)

* New translations settings.php (Spanish)

* New translations auth.php (German)

* New translations passwords.php (German)

* New translations settings.php (German)

* New translations activities.php (German)

* New translations auth.php (German)

* New translations auth.php (German Informal)

* New translations common.php (German)

* New translations entities.php (German)

* New translations errors.php (German)

* New translations errors.php (German Informal)

* New translations settings.php (German)

* New translations settings.php (German Informal)

* New translations entities.php (Japanese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Slovak)

* New translations entities.php (Slovenian)

* New translations entities.php (Swedish)

* New translations entities.php (Turkish)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Chinese Traditional)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Polish)

* New translations entities.php (Indonesian)

* New translations entities.php (Persian)

* New translations entities.php (Croatian)

* New translations entities.php (Estonian)

* New translations entities.php (Latvian)

* New translations entities.php (Bosnian)

* New translations entities.php (Russian)

* New translations entities.php (Dutch)

* New translations entities.php (Portuguese)

* New translations entities.php (Bulgarian)

* New translations entities.php (Ukrainian)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (Norwegian Bokmal)

* New translations entities.php (French)

* New translations entities.php (Spanish)

* New translations entities.php (Arabic)

* New translations entities.php (Catalan)

* New translations entities.php (Lithuanian)

* New translations entities.php (Czech)

* New translations entities.php (Danish)

* New translations entities.php (German)

* New translations entities.php (Hebrew)

* New translations entities.php (Hungarian)

* New translations entities.php (Italian)

* New translations entities.php (Korean)

* New translations entities.php (German Informal)

* New translations entities.php (Spanish)

* New translations auth.php (Portuguese)

* New translations common.php (Portuguese)

* New translations errors.php (Portuguese)

* New translations settings.php (Portuguese)

* New translations activities.php (French)

* New translations activities.php (French)

* New translations auth.php (French)

* New translations common.php (French)

* New translations entities.php (French)

* New translations settings.php (French)

* New translations activities.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations auth.php (Spanish, Argentina)

* New translations common.php (Spanish, Argentina)

* New translations activities.php (German Informal)

* New translations common.php (German Informal)

* New translations settings.php (Spanish, Argentina)

* New translations activities.php (Chinese Simplified)

* New translations activities.php (Chinese Simplified)

* New translations auth.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations activities.php (Estonian)

* New translations auth.php (Estonian)

* New translations common.php (Estonian)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Estonian)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Estonian)

* New translations settings.php (Estonian)

* New translations validation.php (Estonian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations entities.php (Italian)

* New translations settings.php (Italian)

* New translations activities.php (Russian)

* New translations auth.php (Russian)

* New translations common.php (Russian)

* New translations activities.php (Russian)

* New translations entities.php (Russian)

* New translations settings.php (Russian)

* New translations activities.php (Japanese)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Arabic)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations auth.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations auth.php (Czech)

* New translations auth.php (Czech)

* New translations activities.php (Latvian)

* New translations auth.php (Latvian)

* New translations common.php (Latvian)

* New translations entities.php (Latvian)

* New translations settings.php (Latvian)

* New translations activities.php (Latvian)

* New translations settings.php (Latvian)

* New translations activities.php (Italian)

* New translations entities.php (Italian)

* New translations activities.php (Italian)

* New translations settings.php (Italian)

* New translations common.php (Japanese)

* New translations settings.php (French)

* New translations common.php (Vietnamese)

* New translations common.php (Portuguese, Brazilian)

* New translations common.php (Indonesian)

* New translations common.php (Persian)

* New translations common.php (Croatian)

* New translations common.php (Estonian)

* New translations common.php (Latvian)

* New translations common.php (Bosnian)

* New translations common.php (German Informal)

* New translations settings.php (Spanish)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Arabic)

* New translations settings.php (Bulgarian)

* New translations settings.php (Catalan)

* New translations settings.php (Czech)

* New translations settings.php (Danish)

* New translations settings.php (German)

* New translations settings.php (Hebrew)

* New translations settings.php (Hungarian)

* New translations settings.php (Italian)

* New translations settings.php (Japanese)

* New translations common.php (Chinese Traditional)

* New translations common.php (Turkish)

* New translations common.php (Portuguese)

* New translations common.php (Danish)

* New translations common.php (Ukrainian)

* New translations common.php (Spanish, Argentina)

* New translations common.php (Norwegian Bokmal)

* New translations settings.php (Ukrainian)

* New translations common.php (French)

* New translations common.php (Spanish)

* New translations common.php (Arabic)

* New translations common.php (Bulgarian)

* New translations common.php (Catalan)

* New translations common.php (Czech)

* New translations common.php (German)

* New translations common.php (Swedish)

* New translations common.php (Hebrew)

* New translations common.php (Hungarian)

* New translations common.php (Italian)

* New translations common.php (Korean)

* New translations common.php (Lithuanian)

* New translations common.php (Dutch)

* New translations common.php (Polish)

* New translations common.php (Russian)

* New translations common.php (Slovak)

* New translations common.php (Slovenian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Bosnian)

* New translations settings.php (Latvian)

* New translations settings.php (Estonian)

* New translations settings.php (Croatian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Persian)

* New translations settings.php (Indonesian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Dutch)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (German Informal)

* New translations common.php (Estonian)

* New translations entities.php (Estonian)

* New translations settings.php (Estonian)

* New translations common.php (Spanish)

* New translations settings.php (Spanish)

* New translations entities.php (French)

* New translations settings.php (French)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations common.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)
2022-01-06 12:02:49 +00:00
Dan Brown
2312d07bb5 Removed old book sort permission test
Permission handling now done via other means with more extensive
permissions testing in SortTest class.
2022-01-05 16:46:03 +00:00
Dan Brown
fbd388ba4c Aligned chapter move permissions with page move permissions 2022-01-05 16:18:19 +00:00
Dan Brown
d3ca23b195 Added additional permission checks and tests for book sorts
- Aligned permissions control with move operations to check
  delete/create permissions against old/new locations.
- Added tests to cover additional permissions scenarios.
2022-01-05 15:42:59 +00:00
Dan Brown
553954ad18 Altered sort permission checking and started tests
Previous implemenations were hard to read so changing to be more
logically simplistic. Still needs further coverage in tests and
review/alignment of permissions to use.
2022-01-05 14:39:21 +00:00
Dan Brown
d8c45f5746 Changed model loading and permission checking on book sort
Models are now loaded into their own map to then be used for sorting and
reporting back of changed books. Prevents akward logic ordering issues
of before where some bits of code assumed/hoped for loaded models on
abstract data structures.

New levels of permissions are now checked for items within the
sort operation. Needs testing to cover.
2022-01-04 21:09:34 +00:00
Dan Brown
edc7c12edf Refactored sort system a little
To standardise the handled data format a little better.
2022-01-04 17:31:57 +00:00
Dan Brown
a72bd75e3a Added page titles to many missing app areas
Many pages were missing their unique tab/page titles
so this change is just to distribute them back over
many common areas where they were missing.
2022-01-04 13:33:24 +00:00
Dan Brown
31f1dca8a8 Added detection and thumbnail bypass for apng images
Adds apng sniffing when generating thumbnails with retained ratios to
serve the original image files, as we do for GIF images, to prevent
the image being resized to a static version.

Is more tricky than GIF since apng file mimes and extensions
are the same as png, we have to detect part of the file header
to sniff the type. Means we have to sniff at a later stage
than GIF since we have to load the image file data.

Made some changes to the image thubmnail caching while doing
this work to fit in with this handling.

Added test to cover.
For #3136.
2022-01-04 13:10:35 +00:00
Dan Brown
819ec55b1b Fixed code block language parsing issue
Language parsing of code blocks could falter on pasted code blocks due
to the lanuage being parsed with a space which would throw an error when
used as a css class.
This adds more extensive language parsing to be safer.

Fixes #3133
2022-01-04 11:54:24 +00:00
Dan Brown
dba506a20e Added search autofocus on entity-selector-popup
Closes #3127
2022-01-04 11:30:44 +00:00
Dan Brown
d0de4fd8f9 Fixed failing webhook test cases 2022-01-03 19:51:13 +00:00
Dan Brown
00eedafbfd Added timeout and debugging statuses to webhooks
- Added a user-configurable timeout option to webhooks.
- Added webhook fields for last-call/error datetime, in addition to last
  error string, which are shown on  webhook edit view.

Related to #3122
2022-01-03 19:42:48 +00:00
Dan Brown
6e18620a0a Added webhook call http exception handling
Will now catch and log errors on events such as http timeouts.
For #3122
2022-01-03 18:37:56 +00:00
Dan Brown
fe54c7f27a Added webhook_call_before theme event hook 2022-01-03 18:22:03 +00:00
Dan Brown
65830b428c Fixed linked images being micro on pdf export
Was caused by max-width: 100% causing confusion when images were
inside an anchor. This change resets that property on PDF
exports allowing full width images to be shown as so
without affecting smaller sizes.

Fixes #3120
2022-01-01 18:18:37 +00:00
Dan Brown
b438e0187c Updated webhooks list to not squash events/status
Closes #3135
2022-01-01 17:43:33 +00:00
Dan Brown
8614775c14 Updated sponsors in readme 2021-12-30 16:43:28 +00:00
279 changed files with 8057 additions and 1000 deletions

View File

@@ -206,3 +206,7 @@ Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
Nguyen Hung Phuong (hnwolf) :: Vietnamese
Umut ERGENE (umutergene67) :: Turkish
Tomáš Batelka (Vofy) :: Czech
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
Zarik (3apuk) :: Russian
Ali Shaatani (a.shaatani) :: Arabic

6
.gitignore vendored
View File

@@ -5,10 +5,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/storage/images

49
TODO Normal file
View File

@@ -0,0 +1,49 @@
### Next
- Table cell height resize & cell width resize via width style
- Column resize source: https://github.com/ProseMirror/prosemirror-tables/blob/master/src/columnresizing.js
- Have updated column resizing to set cell widths
- Now need to handle table overall size on change, then heights.
- Details/Summary
- Need view to control summary editability, make readonly but editable via popover.
- Need some default styles to visualise details boundary.
- Markdown parser needs to be updated to handle separate open/close tags for blocks.
### In-Progress
- Tables
- Details/Summary
### Features
- Images
- Drawings
- LTR/RTL control
- Fullscreen
- Paste Image Uploading
- Drag + Drop Image Uploading
- Checkbox/TODO list items
- Code blocks
- Indents
- Attachment integration (Drag & drop)
- Template system integration.
### Improvements
- List type changing.
- Color picker options should have "clear" option.
- Color picker buttons should be split, with button to re-apply last selected color.
- Color picker options should change color if different instead of remove.
- Clear formatting, If no selection range, clear the formatting of parent block.
- If no marks, clear the block type if text type?
- Remove links button? (Action already in place if link href is empty).
- Links - Validate URL.
- Links - Integrate entity picker.
- iFrame - Parse iframe HTML & auto-convert youtube/vimeo urls to embeds.
### Notes
- Use NodeViews for embedded content (Code, Drawings) where control is needed.
- Probably still easiest to have seperate (codemirror) MD editor. Can alter display output via NodeViews to make MD like
but its tricky since editing the markdown content would change the block definition/type while editing.

View File

@@ -4,8 +4,10 @@ namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Theming\ThemeEvents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -68,14 +70,32 @@ class DispatchWebhookJob implements ShouldQueue
*/
public function handle()
{
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout(3)
->post($this->webhook->endpoint, $this->buildWebhookData());
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
$webhookData = $themeResponse ?? $this->buildWebhookData();
$lastError = null;
if ($response->failed()) {
try {
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout($this->webhook->timeout)
->post($this->webhook->endpoint, $webhookData);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
if (isset($response) && $response->failed()) {
$lastError = "Response status from endpoint was {$response->status()}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
}
$this->webhook->last_called_at = now();
if ($lastError) {
$this->webhook->last_errored_at = now();
$this->webhook->last_error = $lastError;
}
$this->webhook->save();
}
protected function buildWebhookData(): array

View File

@@ -3,6 +3,7 @@
namespace BookStack\Actions;
use BookStack\Interfaces\Loggable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -14,13 +15,22 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $endpoint
* @property Collection $trackedEvents
* @property bool $active
* @property int $timeout
* @property string $last_error
* @property Carbon $last_called_at
* @property Carbon $last_errored_at
*/
class Webhook extends Model implements Loggable
{
protected $fillable = ['name', 'endpoint'];
protected $fillable = ['name', 'endpoint', 'timeout'];
use HasFactory;
protected $casts = [
'last_called_at' => 'datetime',
'last_errored_at' => 'datetime',
];
/**
* Define the tracked event relation a webhook.
*/

View File

@@ -10,6 +10,7 @@ use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;
@@ -85,15 +86,19 @@ class ChapterRepo
* 'book:<id>' (book:5).
*
* @throws MoveOperationException
* @throws PermissionsException
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
/** @var Book $parent */
$parent = $this->findParentByIdentifier($parentIdentifier);
if (is_null($parent)) {
throw new MoveOperationException('Book to move chapter into not found');
}
if (!userCan('chapter-create', $parent)) {
throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
}
$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@@ -328,7 +328,7 @@ class PageRepo
public function move(Page $page, string $parentIdentifier): Entity
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
if (is_null($parent)) {
throw new MoveOperationException('Book or chapter to move page into not found');
}

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\SortOperationException;
use Illuminate\Support\Collection;
class BookContents
@@ -107,111 +106,209 @@ class BookContents
}
/**
* Sort the books content using the given map.
* The map is a single-dimension collection of objects in the following format:
* {
* +"id": "294" (ID of item)
* +"sort": 1 (Sort order index)
* +"parentChapter": false (ID of parent chapter, as string, or false)
* +"type": "page" (Entity type of item)
* +"book": "1" (Id of book to place item in)
* }.
*
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @throws SortOperationException
* @returns Book[]
*/
public function sortUsingMap(Collection $sortMap): Collection
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$this->loadModelsIntoSortMap($sortMap);
$booksInvolved = $this->getBooksInvolvedInSort($sortMap);
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
$sortMap->each(function ($mapItem) {
$this->applySortUpdates($mapItem);
});
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
// Update permissions and activity.
$booksInvolved->each(function (Book $book) {
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return strpos($key, 'book:') === 0;
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
});
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required.
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(\stdClass $sortMapItem)
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $sortMapItem->model;
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
$bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
$chapterChanged = ($model instanceof Page) && intval($model->chapter_id) !== $sortMapItem->parentChapter;
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($sortMapItem->book);
$model->changeBook($newBook->id);
}
if ($chapterChanged) {
$model->chapter_id = intval($sortMapItem->parentChapter);
$model->save();
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = intval($sortMapItem->sort);
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model->save();
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*/
protected function loadModelsIntoSortMap(Collection $sortMap): void
{
$keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
return $sortMapItem->type . ':' . $sortMapItem->id;
});
$pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
$chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
$pages = Page::visible()->whereIn('id', $pageIds)->get();
$chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
foreach ($pages as $page) {
$sortItem = $keyMap->get('page:' . $page->id);
$sortItem->model = $page;
}
foreach ($chapters as $chapter) {
$sortItem = $keyMap->get('chapter:' . $chapter->id);
$sortItem->model = $chapter;
}
}
/**
* Get the books involved in a sort.
* The given sort map should have its models loaded first.
*
* @throws SortOperationException
* @return array<string, Entity>
*/
protected function getBooksInvolvedInSort(Collection $sortMap): Collection
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$bookIdsInvolved = collect([$this->book->id]);
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
$books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
if (count($books) !== count($bookIdsInvolved)) {
throw new SortOperationException('Could not find all books requested in sort operation');
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
return $books;
$pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace BookStack\Entities\Tools;
class BookSortMap
{
/**
* @var BookSortMapItem[]
*/
protected $mapData = [];
public function addItem(BookSortMapItem $mapItem): void
{
$this->mapData[] = $mapItem;
}
/**
* @return BookSortMapItem[]
*/
public function all(): array
{
return $this->mapData;
}
public static function fromJson(string $json): self
{
$map = new BookSortMap();
$mapData = json_decode($json);
foreach ($mapData as $mapDataItem) {
$item = new BookSortMapItem(
intval($mapDataItem->id),
intval($mapDataItem->sort),
$mapDataItem->parentChapter ? intval($mapDataItem->parentChapter) : null,
$mapDataItem->type,
intval($mapDataItem->book)
);
$map->addItem($item);
}
return $map;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace BookStack\Entities\Tools;
class BookSortMapItem
{
/**
* @var int
*/
public $id;
/**
* @var int
*/
public $sort;
/**
* @var ?int
*/
public $parentChapterId;
/**
* @var string
*/
public $type;
/**
* @var int
*/
public $parentBookId;
public function __construct(int $id, int $sort, ?int $parentChapterId, string $type, int $parentBookId)
{
$this->id = $id;
$this->sort = $sort;
$this->parentChapterId = $parentChapterId;
$this->type = $type;
$this->parentBookId = $parentBookId;
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace BookStack\Exceptions;
use Exception;
class SortOperationException extends Exception
{
}

View File

@@ -29,6 +29,8 @@ class MfaBackupCodesController extends Controller
$downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
$this->setPageTitle(trans('auth.mfa_gen_backup_codes_title'));
return view('mfa.backup-codes-generate', [
'codes' => $codes,
'downloadUrl' => $downloadUrl,

View File

@@ -21,6 +21,8 @@ class MfaController extends Controller
->get(['id', 'method'])
->groupBy('method');
$this->setPageTitle(trans('auth.mfa_setup'));
return view('mfa.setup', [
'userMethods' => $userMethods,
]);

View File

@@ -34,6 +34,8 @@ class MfaTotpController extends Controller
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
return view('mfa.totp-generate', [
'url' => $qrCodeUrl,
'svg' => $svg,

View File

@@ -3,10 +3,9 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Exceptions\SortOperationException;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
@@ -59,20 +58,14 @@ class BookSortController extends Controller
return redirect($book->getUrl());
}
$sortMap = collect(json_decode($request->get('sort-tree')));
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$bookContents = new BookContents($book);
$booksInvolved = collect();
try {
$booksInvolved = $bookContents->sortUsingMap($sortMap);
} catch (SortOperationException $exception) {
$this->showPermissionError();
}
$booksInvolved = $bookContents->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
Activity::add(ActivityType::BOOK_SORT, $book);
});
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
}
return redirect($book->getUrl());
}

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -180,6 +181,8 @@ class ChapterController extends Controller
try {
$newBook = $this->chapterRepo->move($chapter, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (MoveOperationException $exception) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));

View File

@@ -48,6 +48,8 @@ abstract class Controller extends BaseController
/**
* On a permission error redirect to home and display.
* the error as a notification.
*
* @return never
*/
protected function showPermissionError()
{

View File

@@ -21,6 +21,8 @@ class FavouriteController extends Controller
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
$this->setPageTitle(trans('entities.my_favourites'));
return view('common.detailed-listing-with-more', [
'title' => trans('entities.my_favourites'),
'entities' => $favourites->slice(0, $viewCount),

View File

@@ -368,6 +368,8 @@ class PageController extends Controller
->paginate(20)
->setPath(url('/pages/recently-updated'));
$this->setPageTitle(trans('entities.recently_updated_pages'));
return view('common.detailed-listing-paginated', [
'title' => trans('entities.recently_updated_pages'),
'entities' => $pages,
@@ -410,11 +412,9 @@ class PageController extends Controller
try {
$parent = $this->pageRepo->move($page, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();

View File

@@ -29,6 +29,8 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
$roles = $this->permissionsRepo->getAllRoles();
$this->setPageTitle(trans('settings.roles'));
return view('settings.roles.index', ['roles' => $roles]);
}
@@ -49,6 +51,8 @@ class RoleController extends Controller
$role->display_name .= ' (' . trans('common.copy') . ')';
}
$this->setPageTitle(trans('settings.role_create'));
return view('settings.roles.create', ['role' => $role]);
}
@@ -82,6 +86,8 @@ class RoleController extends Controller
throw new PermissionsException(trans('errors.role_cannot_be_edited'));
}
$this->setPageTitle(trans('settings.role_edit'));
return view('settings.roles.edit', ['role' => $role]);
}
@@ -116,6 +122,8 @@ class RoleController extends Controller
$blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
$roles->prepend($blankRole);
$this->setPageTitle(trans('settings.role_delete'));
return view('settings.roles.delete', ['role' => $role, 'roles' => $roles]);
}

View File

@@ -32,6 +32,8 @@ class TagController extends Controller
'name' => $nameFilter,
]));
$this->setPageTitle(trans('entities.tags'));
return view('tags.index', [
'tags' => $tags,
'search' => $search,

View File

@@ -18,6 +18,8 @@ class UserProfileController extends Controller
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);
$this->setPageTitle($user->name);
return view('users.profile', [
'user' => $user,
'activity' => $userActivity,

View File

@@ -25,6 +25,8 @@ class WebhookController extends Controller
->with('trackedEvents')
->get();
$this->setPageTitle(trans('settings.webhooks'));
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
}
@@ -33,6 +35,8 @@ class WebhookController extends Controller
*/
public function create()
{
$this->setPageTitle(trans('settings.webhooks_create'));
return view('settings.webhooks.create');
}
@@ -46,6 +50,7 @@ class WebhookController extends Controller
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
]);
$webhook = new Webhook($validated);
@@ -68,6 +73,8 @@ class WebhookController extends Controller
->with('trackedEvents')
->findOrFail($id);
$this->setPageTitle(trans('settings.webhooks_edit'));
return view('settings.webhooks.edit', ['webhook' => $webhook]);
}
@@ -81,6 +88,7 @@ class WebhookController extends Controller
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
]);
/** @var Webhook $webhook */
@@ -103,6 +111,8 @@ class WebhookController extends Controller
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$this->setPageTitle(trans('settings.webhooks_delete'));
return view('settings.webhooks.delete', ['webhook' => $webhook]);
}

View File

@@ -79,4 +79,20 @@ class ThemeEvents
* @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
*/
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
/**
* Webhook call before event.
* Runs before a webhook endpoint is called. Allows for customization
* of the data format & content within the webhook POST request.
* Provides the original event name as a string (see \BookStack\Actions\ActivityType)
* along with the webhook instance along with the event detail which may be a
* "Loggable" model type or a string.
* If the listener returns a non-null value, that will be used as the POST data instead
* of the system default.
*
* @param string $event
* @param \BookStack\Actions\Webhook $webhook
* @param string|\BookStack\Interfaces\Loggable $detail
*/
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
}

View File

@@ -228,6 +228,21 @@ class ImageService
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image and image data is apng.
*/
protected function isApngData(Image $image, string &$imageData): bool
{
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
if (!$isPng) {
return false;
}
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
return strpos($initialHeader, 'acTL') !== false;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
@@ -238,6 +253,7 @@ class ImageService
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
{
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
@@ -246,19 +262,35 @@ class ImageService
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
return $this->getPublicUrl($thumbFilePath);
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
// Return path if in cache
$cachedThumbPath = $this->cache->get($thumbCacheKey);
if ($cachedThumbPath) {
return $this->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$storage = $this->getStorageDisk($image->type);
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
$thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
$imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
$this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
return $this->getPublicUrl($image->path);
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Uploads;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\User;
use BookStack\Exceptions\HttpFetchException;
use Exception;
@@ -16,6 +17,7 @@ class UserAvatars
{
$this->imageService = $imageService;
$this->http = $http;
$ldapService = app()->make(LdapService::class);
}
/**

View File

@@ -17,6 +17,7 @@ class WebSafeMimeSniffer
'application/json',
'application/octet-stream',
'application/pdf',
'image/apng',
'image/bmp',
'image/jpeg',
'image/png',

View File

@@ -20,6 +20,7 @@ class WebhookFactory extends Factory
'name' => 'My webhook for ' . $this->faker->country(),
'endpoint' => $this->faker->url,
'active' => true,
'timeout' => 3,
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddWebhooksTimeoutErrorColumns extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('webhooks', function (Blueprint $table) {
$table->unsignedInteger('timeout')->default(3);
$table->text('last_error')->default('');
$table->timestamp('last_called_at')->nullable();
$table->timestamp('last_errored_at')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('webhooks', function (Blueprint $table) {
$table->dropColumn('timeout');
$table->dropColumn('last_error');
$table->dropColumn('last_called_at');
$table->dropColumn('last_errored_at');
});
}
}

432
package-lock.json generated
View File

@@ -7,9 +7,18 @@
"dependencies": {
"clipboard": "^2.0.8",
"codemirror": "^5.63.3",
"crelt": "^1.0.5",
"dropzone": "^5.9.3",
"markdown-it": "^12.2.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-commands": "^1.1.12",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.6.0",
"prosemirror-model": "^1.15.0",
"prosemirror-schema-list": "^1.1.6",
"prosemirror-state": "^1.3.4",
"prosemirror-tables": "^1.1.1",
"prosemirror-view": "^1.23.2",
"sortablejs": "^1.14.0"
},
"devDependencies": {
@@ -219,6 +228,11 @@
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
},
"node_modules/crelt": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.5.tgz",
"integrity": "sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA=="
},
"node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -1227,6 +1241,11 @@
"integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==",
"dev": true
},
"node_modules/orderedmap": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-1.1.1.tgz",
"integrity": "sha512-3Ux8um0zXbVacKUkcytc0u3HgC0b0bBLT+I60r2J/En72cI0nZffqrA7Xtf2Hqs27j1g82llR5Mhbd0Z1XW4AQ=="
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -1345,6 +1364,193 @@
"node": ">=4"
}
},
"node_modules/prosemirror-commands": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.1.12.tgz",
"integrity": "sha512-+CrMs3w/ZVPSkR+REg8KL/clyFLv/1+SgY/OMN+CB22Z24j9TZDje72vL36lOZ/E4NeRXuiCcmENcW/vAcG67A==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.4.0.tgz",
"integrity": "sha512-6+YwTjmqDwlA/Dm+5wK67ezgqgjA/MhSDgaNxKUzH97SmeuWFXyLeDRxxOPZeSo7yTxcDGUCWTEjmQZsVBuMrQ==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-example-setup": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.1.2.tgz",
"integrity": "sha512-MTpIMyqk08jFnzxeRMCinCEMtVSTUtxKgQBGxfCbVe9C6zIOqp9qZZJz5Ojaad1GETySyuj8+OIHHvQsIaaaGQ==",
"dependencies": {
"prosemirror-commands": "^1.0.0",
"prosemirror-dropcursor": "^1.0.0",
"prosemirror-gapcursor": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-inputrules": "^1.0.0",
"prosemirror-keymap": "^1.0.0",
"prosemirror-menu": "^1.0.0",
"prosemirror-schema-list": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.2.0.tgz",
"integrity": "sha512-yCLy5+0rVqLir/KcHFathQj4Rf8aRHi80FmEfKtM0JmyzvwdomslLzDZ/pX4oFhFKDgjl/WBBBFNqDyNifWg7g==",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.2.0.tgz",
"integrity": "sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ==",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.1.3.tgz",
"integrity": "sha512-ZaHCLyBtvbyIHv0f5p6boQTIJjlD6o2NPZiEaZWT2DA+j591zS29QQEMT4lBqwcLW3qRSf7ZvoKNbf05YrsStw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz",
"integrity": "sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.6.0.tgz",
"integrity": "sha512-y/gRpJIIrNArtkyMax7ypYafb+ZMjddbVHI+AwlcUfCLCCXK57cOmfBMKYVq9kdEKJYVdYHdoyWsVNn1nWLHUg==",
"dependencies": {
"markdown-it": "^10.0.0",
"prosemirror-model": "^1.0.0"
}
},
"node_modules/prosemirror-markdown/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/prosemirror-markdown/node_modules/entities": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="
},
"node_modules/prosemirror-markdown/node_modules/linkify-it": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
"node_modules/prosemirror-markdown/node_modules/markdown-it": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
"integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
"dependencies": {
"argparse": "^1.0.7",
"entities": "~2.0.0",
"linkify-it": "^2.0.0",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"bin": {
"markdown-it": "bin/markdown-it.js"
}
},
"node_modules/prosemirror-menu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.1.4.tgz",
"integrity": "sha512-2ROsji/X9ciDnVSRvSTqFygI34GEdHfQSsK4zBKjPxSEroeiHHcdRMS1ofNIf2zM0Vpp5/YqfpxynElymQkqzg==",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.15.0.tgz",
"integrity": "sha512-hQJv7SnIhlAy9ga3lhPPgaufhvCbQB9tHwscJ9E1H1pPHmN8w5V/lURueoYv9Kc3/bpNWoyHa8r3g//m7N0ChQ==",
"dependencies": {
"orderedmap": "^1.1.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.1.6.tgz",
"integrity": "sha512-aFGEdaCWmJzouZ8DwedmvSsL50JpRkqhQ6tcpThwJONVVmCgI36LJHtoQ4VGZbusMavaBhXXr33zyD2IVsTlkw==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-state": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.3.4.tgz",
"integrity": "sha512-Xkkrpd1y/TQ6HKzN3agsQIGRcLckUMA9u3j207L04mt8ToRgpGeyhbVv0HI7omDORIBHjR29b7AwlATFFf2GLA==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.1.1.tgz",
"integrity": "sha512-LmCz4jrlqQZRsYRDzCRYf/pQ5CUcSOyqZlAj5kv67ZWBH1SVLP2U9WJEvQfimWgeRlIz0y0PQVqO1arRm1+woA==",
"dependencies": {
"prosemirror-keymap": "^1.1.2",
"prosemirror-model": "^1.8.1",
"prosemirror-state": "^1.3.1",
"prosemirror-transform": "^1.2.1",
"prosemirror-view": "^1.13.3"
}
},
"node_modules/prosemirror-transform": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.3.3.tgz",
"integrity": "sha512-9NLVXy1Sfa2G6qPqhWMkEvwQQMTw7OyTqOZbJaGQWsCeH3hH5Cw+c5eNaLM1Uu75EyKLsEZhJ93XpHJBa6RX8A==",
"dependencies": {
"prosemirror-model": "^1.0.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.23.2.tgz",
"integrity": "sha512-iPgRw6tpcN+KH1yKmSnRmDKsJBVkWLFP6laHcz9rh/n0Ndz7YKKCDldtw6FhHBYoWmZeubbhV/rrQW0VCDG9iw==",
"dependencies": {
"prosemirror-model": "^1.14.3",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@@ -1408,6 +1614,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rope-sequence": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.2.tgz",
"integrity": "sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg=="
},
"node_modules/sass": {
"version": "1.43.4",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz",
@@ -1521,6 +1732,11 @@
"integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==",
"dev": true
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
@@ -1658,6 +1874,11 @@
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.4.tgz",
"integrity": "sha512-tOhfEwEzFLJzf6d1ZPkYfGj+FWhIpBux9ppoP3rlclw3Z0BZv3N7b7030Z1kYth+6rDuAsXUFr+d0VE6Ed1ikw=="
},
"node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
@@ -1926,6 +2147,11 @@
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
},
"crelt": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.5.tgz",
"integrity": "sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA=="
},
"cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -2630,6 +2856,11 @@
"integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==",
"dev": true
},
"orderedmap": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-1.1.1.tgz",
"integrity": "sha512-3Ux8um0zXbVacKUkcytc0u3HgC0b0bBLT+I60r2J/En72cI0nZffqrA7Xtf2Hqs27j1g82llR5Mhbd0Z1XW4AQ=="
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -2709,6 +2940,192 @@
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
},
"prosemirror-commands": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.1.12.tgz",
"integrity": "sha512-+CrMs3w/ZVPSkR+REg8KL/clyFLv/1+SgY/OMN+CB22Z24j9TZDje72vL36lOZ/E4NeRXuiCcmENcW/vAcG67A==",
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-dropcursor": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.4.0.tgz",
"integrity": "sha512-6+YwTjmqDwlA/Dm+5wK67ezgqgjA/MhSDgaNxKUzH97SmeuWFXyLeDRxxOPZeSo7yTxcDGUCWTEjmQZsVBuMrQ==",
"requires": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"prosemirror-example-setup": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.1.2.tgz",
"integrity": "sha512-MTpIMyqk08jFnzxeRMCinCEMtVSTUtxKgQBGxfCbVe9C6zIOqp9qZZJz5Ojaad1GETySyuj8+OIHHvQsIaaaGQ==",
"requires": {
"prosemirror-commands": "^1.0.0",
"prosemirror-dropcursor": "^1.0.0",
"prosemirror-gapcursor": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-inputrules": "^1.0.0",
"prosemirror-keymap": "^1.0.0",
"prosemirror-menu": "^1.0.0",
"prosemirror-schema-list": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"prosemirror-gapcursor": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.2.0.tgz",
"integrity": "sha512-yCLy5+0rVqLir/KcHFathQj4Rf8aRHi80FmEfKtM0JmyzvwdomslLzDZ/pX4oFhFKDgjl/WBBBFNqDyNifWg7g==",
"requires": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"prosemirror-history": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.2.0.tgz",
"integrity": "sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ==",
"requires": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"rope-sequence": "^1.3.0"
}
},
"prosemirror-inputrules": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.1.3.tgz",
"integrity": "sha512-ZaHCLyBtvbyIHv0f5p6boQTIJjlD6o2NPZiEaZWT2DA+j591zS29QQEMT4lBqwcLW3qRSf7ZvoKNbf05YrsStw==",
"requires": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-keymap": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz",
"integrity": "sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw==",
"requires": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"prosemirror-markdown": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.6.0.tgz",
"integrity": "sha512-y/gRpJIIrNArtkyMax7ypYafb+ZMjddbVHI+AwlcUfCLCCXK57cOmfBMKYVq9kdEKJYVdYHdoyWsVNn1nWLHUg==",
"requires": {
"markdown-it": "^10.0.0",
"prosemirror-model": "^1.0.0"
},
"dependencies": {
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"entities": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="
},
"linkify-it": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"requires": {
"uc.micro": "^1.0.1"
}
},
"markdown-it": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
"integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
"requires": {
"argparse": "^1.0.7",
"entities": "~2.0.0",
"linkify-it": "^2.0.0",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
}
}
}
},
"prosemirror-menu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.1.4.tgz",
"integrity": "sha512-2ROsji/X9ciDnVSRvSTqFygI34GEdHfQSsK4zBKjPxSEroeiHHcdRMS1ofNIf2zM0Vpp5/YqfpxynElymQkqzg==",
"requires": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"prosemirror-model": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.15.0.tgz",
"integrity": "sha512-hQJv7SnIhlAy9ga3lhPPgaufhvCbQB9tHwscJ9E1H1pPHmN8w5V/lURueoYv9Kc3/bpNWoyHa8r3g//m7N0ChQ==",
"requires": {
"orderedmap": "^1.1.0"
}
},
"prosemirror-schema-list": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.1.6.tgz",
"integrity": "sha512-aFGEdaCWmJzouZ8DwedmvSsL50JpRkqhQ6tcpThwJONVVmCgI36LJHtoQ4VGZbusMavaBhXXr33zyD2IVsTlkw==",
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-state": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.3.4.tgz",
"integrity": "sha512-Xkkrpd1y/TQ6HKzN3agsQIGRcLckUMA9u3j207L04mt8ToRgpGeyhbVv0HI7omDORIBHjR29b7AwlATFFf2GLA==",
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-tables": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.1.1.tgz",
"integrity": "sha512-LmCz4jrlqQZRsYRDzCRYf/pQ5CUcSOyqZlAj5kv67ZWBH1SVLP2U9WJEvQfimWgeRlIz0y0PQVqO1arRm1+woA==",
"requires": {
"prosemirror-keymap": "^1.1.2",
"prosemirror-model": "^1.8.1",
"prosemirror-state": "^1.3.1",
"prosemirror-transform": "^1.2.1",
"prosemirror-view": "^1.13.3"
}
},
"prosemirror-transform": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.3.3.tgz",
"integrity": "sha512-9NLVXy1Sfa2G6qPqhWMkEvwQQMTw7OyTqOZbJaGQWsCeH3hH5Cw+c5eNaLM1Uu75EyKLsEZhJ93XpHJBa6RX8A==",
"requires": {
"prosemirror-model": "^1.0.0"
}
},
"prosemirror-view": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.23.2.tgz",
"integrity": "sha512-iPgRw6tpcN+KH1yKmSnRmDKsJBVkWLFP6laHcz9rh/n0Ndz7YKKCDldtw6FhHBYoWmZeubbhV/rrQW0VCDG9iw==",
"requires": {
"prosemirror-model": "^1.14.3",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@@ -2757,6 +3174,11 @@
"path-parse": "^1.0.6"
}
},
"rope-sequence": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.2.tgz",
"integrity": "sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg=="
},
"sass": {
"version": "1.43.4",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz",
@@ -2852,6 +3274,11 @@
"integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==",
"dev": true
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
@@ -2959,6 +3386,11 @@
"spdx-expression-parse": "^3.0.0"
}
},
"w3c-keyname": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.4.tgz",
"integrity": "sha512-tOhfEwEzFLJzf6d1ZPkYfGj+FWhIpBux9ppoP3rlclw3Z0BZv3N7b7030Z1kYth+6rDuAsXUFr+d0VE6Ed1ikw=="
},
"which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",

View File

@@ -7,6 +7,8 @@
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
"build:js_editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main",
"build:js_editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js_editor:dev\"",
"build": "npm-run-all --parallel build:*:dev",
"production": "npm-run-all --parallel build:*:production",
"dev": "npm-run-all --parallel watch livereload",
@@ -25,9 +27,18 @@
"dependencies": {
"clipboard": "^2.0.8",
"codemirror": "^5.63.3",
"crelt": "^1.0.5",
"dropzone": "^5.9.3",
"markdown-it": "^12.2.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-commands": "^1.1.12",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.6.0",
"prosemirror-model": "^1.15.0",
"prosemirror-schema-list": "^1.1.6",
"prosemirror-state": "^1.3.4",
"prosemirror-tables": "^1.1.1",
"prosemirror-view": "^1.23.2",
"sortablejs": "^1.14.0"
}
}

72
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */

File diff suppressed because one or more lines are too long

View File

@@ -34,13 +34,17 @@ Big thanks to these companies for supporting the project.
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
[View all sponsors](https://github.com/sponsors/ssddanbrown).
#### Bronze Sponsors
#### Silver Sponsor
<table><tbody><tr>
<td><a href="https://www.diagrams.net/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
<img width="420" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
</a></td>
</tr></tbody></table>
#### Bronze Sponsor
<table><tbody><tr>
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
</a></td>

View File

@@ -7,6 +7,8 @@ class EntitySelectorPopup {
setup() {
this.elem = this.$el;
this.selectButton = this.$refs.select;
this.searchInput = this.$refs.searchInput;
window.EntitySelectorPopup = this;
this.callback = null;
@@ -20,6 +22,7 @@ class EntitySelectorPopup {
show(callback) {
this.callback = callback;
this.elem.components.popup.show();
this.searchInput.focus();
}
hide() {

18
resources/js/editor.js Normal file
View File

@@ -0,0 +1,18 @@
import MarkdownView from "./editor/MarkdownView";
import ProseMirrorView from "./editor/ProseMirrorView";
// Next step: https://prosemirror.net/examples/menu/
const place = document.querySelector("#editor");
let view = new ProseMirrorView(place, document.getElementById('content').innerHTML);
const markdownToggle = document.getElementById('markdown-toggle');
markdownToggle.addEventListener('change', event => {
const View = markdownToggle.checked ? MarkdownView : ProseMirrorView;
if (view instanceof View) return
const content = view.content
console.log(content);
view.destroy()
view = new View(place, content)
view.focus()
});

View File

@@ -0,0 +1,28 @@
import {htmlToDoc, docToHtml} from "./util";
import parser from "./markdown-parser";
import serializer from "./markdown-serializer";
class MarkdownView {
constructor(target, content) {
// Build DOM from content
const htmlDoc = htmlToDoc(content);
const markdown = serializer.serialize(htmlDoc);
this.textarea = target.appendChild(document.createElement("textarea"))
this.textarea.value = markdown;
this.textarea.style.width = '1000px';
this.textarea.style.height = '1000px';
}
get content() {
const markdown = this.textarea.value;
const doc = parser.parse(markdown);
return docToHtml(doc);
}
focus() { this.textarea.focus() }
destroy() { this.textarea.remove() }
}
export default MarkdownView;

View File

@@ -0,0 +1,52 @@
import {EditorState} from "prosemirror-state";
import {EditorView} from "prosemirror-view";
import {exampleSetup} from "prosemirror-example-setup";
import {tableEditing} from "prosemirror-tables";
import {DOMParser} from "prosemirror-model";
import schema from "./schema";
import menu from "./menu";
import nodeViews from "./node-views";
import {stateToHtml} from "./util";
import {columnResizing} from "./plugins/table-resizing";
class ProseMirrorView {
constructor(target, content) {
// Build DOM from content
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.innerHTML = content;
this.view = new EditorView(target, {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(renderDoc.body),
plugins: [
...exampleSetup({schema, menuBar: false}),
menu,
columnResizing(),
tableEditing(),
]
}),
nodeViews,
});
// Fix for native handles (Such as table size handling) in some browsers
document.execCommand("enableObjectResizing", false, "false")
document.execCommand("enableInlineTableEditing", false, "false")
}
get content() {
return stateToHtml(this.view.state);
}
focus() {
this.view.focus()
}
destroy() {
this.view.destroy()
}
}
export default ProseMirrorView;

View File

@@ -0,0 +1,102 @@
/**
* @param {String} attrName
* @param {String} attrValue
* @return {PmCommandHandler}
*/
export function setBlockAttr(attrName, attrValue) {
return function (state, dispatch) {
const ref = state.selection;
const from = ref.from;
const to = ref.to;
let applicable = false;
state.doc.nodesBetween(from, to, function (node, pos) {
if (applicable) {
return false
}
if (!node.isTextblock || node.attrs[attrName] === attrValue) {
return
}
applicable = node.attrs[attrName] !== undefined;
});
if (!applicable) {
return false
}
if (dispatch) {
const tr = state.tr;
tr.doc.nodesBetween(from, to, function (node, pos) {
const nodeAttrs = Object.assign({}, node.attrs);
if (node.attrs[attrName] !== undefined) {
nodeAttrs[attrName] = attrValue;
tr.setBlockType(pos, pos + 1, node.type, nodeAttrs)
}
});
dispatch(tr);
}
return true
}
}
/**
* @param {PmNodeType} blockType
* @return {PmCommandHandler}
*/
export function insertBlockBefore(blockType) {
return function (state, dispatch) {
const startPosition = state.selection.$from.before(1);
if (dispatch) {
dispatch(state.tr.insert(startPosition, blockType.create()));
}
return true
}
}
/**
* @param {Number} rows
* @param {Number} columns
* @param {Object} tableAttrs
* @return {PmCommandHandler}
*/
export function insertTable(rows, columns, tableAttrs) {
return function (state, dispatch) {
if (!dispatch) return true;
const tr = state.tr;
const nodes = state.schema.nodes;
const rowNodes = [];
for (let y = 0; y < rows; y++) {
const rowCells = [];
for (let x = 0; x < columns; x++) {
const cellText = nodes.paragraph.create(null);
rowCells.push(nodes.table_cell.create(null, cellText));
}
rowNodes.push(nodes.table_row.create(null, rowCells));
}
const table = nodes.table.create(tableAttrs, rowNodes);
tr.replaceSelectionWith(table);
dispatch(tr);
return true;
}
}
/**
* @return {PmCommandHandler}
*/
export function removeMarks() {
return function (state, dispatch) {
if (dispatch) {
dispatch(state.tr.removeMark(state.selection.from, state.selection.to, null));
}
return true;
}
}

View File

@@ -0,0 +1,69 @@
import schema from "./schema";
import markdownit from "markdown-it";
import {MarkdownParser, defaultMarkdownParser} from "prosemirror-markdown";
import {htmlToDoc, KeyedMultiStack} from "./util";
const tokens = defaultMarkdownParser.tokens;
// These are really a placeholder on the object to allow the below
// parser.tokenHandlers.html_[block/inline] hacks to work as desired.
tokens.html_block = {block: "callout", noCloseToken: true};
tokens.html_inline = {mark: "underline"};
const tokenizer = markdownit("commonmark", {html: true});
const parser = new MarkdownParser(schema, tokenizer, tokens);
// When we come across HTML blocks we use the document schema to parse them
// into nodes then re-add those back into the parser state.
parser.tokenHandlers.html_block = function(state, tok, tokens, i) {
const contentDoc = htmlToDoc(tok.content || '');
for (const node of contentDoc.content.content) {
state.addNode(node.type, node.attrs, node.content);
}
};
// When we come across inline HTML we parse out the tag and keep track of
// that in a stack, along with the marks they parse out to.
// We open/close the marks within the state depending on the tag open/close type.
const tagStack = new KeyedMultiStack();
parser.tokenHandlers.html_inline = function(state, tok, tokens, i) {
const isClosing = tok.content.startsWith('</');
const isSelfClosing = tok.content.endsWith('/>');
const tagName = parseTagNameFromHtmlTokenContent(tok.content);
if (!isClosing) {
const completeTag = isSelfClosing ? tok.content : `${tok.content}a</${tagName}>`;
const marks = extractMarksFromHtml(completeTag);
tagStack.push(tagName, marks);
for (const mark of marks) {
state.openMark(mark);
}
}
if (isSelfClosing || isClosing) {
const marks = (tagStack.pop(tagName) || []).reverse();
for (const mark of marks) {
state.closeMark(mark);
}
}
}
/**
* @param {String} html
* @return {PmMark[]}
*/
function extractMarksFromHtml(html) {
const contentDoc = htmlToDoc('<p>' + (html || '') + '</p>');
const marks = contentDoc?.content?.content?.[0]?.content?.content?.[0]?.marks;
return marks || [];
}
/**
* @param {string} tokenContent
* @return {string}
*/
function parseTagNameFromHtmlTokenContent(tokenContent) {
return tokenContent.split(' ')[0].replace(/[<>\/]/g, '').toLowerCase();
}
export default parser;

View File

@@ -0,0 +1,138 @@
import {MarkdownSerializer, defaultMarkdownSerializer, MarkdownSerializerState} from "prosemirror-markdown";
import {docToHtml} from "./util";
const nodes = defaultMarkdownSerializer.nodes;
const marks = defaultMarkdownSerializer.marks;
nodes.callout = function (state, node) {
writeNodeAsHtml(state, node);
};
nodes.table = function (state, node) {
writeNodeAsHtml(state, node);
};
nodes.iframe = function (state, node) {
writeNodeAsHtml(state, node);
};
nodes.details = function (state, node) {
wrapNodeWithHtml(state, node, '<details>', '</details>');
};
nodes.details_summary = function(state, node) {
writeNodeAsHtml(state, node);
};
function isPlainURL(link, parent, index, side) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) {
return false
}
const content = parent.child(index + (side < 0 ? -1 : 0));
if (!content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link) {
return false
}
if (index == (side < 0 ? 1 : parent.childCount - 1)) {
return true
}
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks)
}
marks.link = {
open(state, mark, parent, index) {
const attrs = mark.attrs;
if (attrs.target) {
return `<a href="${attrs.target}" ${attrs.title ? `title="${attrs.title}"` : ''} target="${attrs.target}">`
}
return isPlainURL(mark, parent, index, 1) ? "<" : "["
},
close(state, mark, parent, index) {
if (mark.attrs.target) {
return `</a>`;
}
return isPlainURL(mark, parent, index, -1) ? ">"
: "](" + state.esc(mark.attrs.href) + (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + ")"
}
};
marks.underline = {
open: '<span style="text-decoration: underline;">',
close: '</span>',
};
marks.strike = {
open: '<span style="text-decoration: line-through;">',
close: '</span>',
};
marks.superscript = {
open: '<sup>',
close: '</sup>',
};
marks.subscript = {
open: '<sub>',
close: '</sub>',
};
marks.text_color = {
open(state, mark, parent, index) {
return `<span style="color: ${mark.attrs.color};">`
},
close: '</span>',
};
marks.background_color = {
open(state, mark, parent, index) {
return `<span style="background-color: ${mark.attrs.color};">`
},
close: '</span>',
};
/**
* @param {MarkdownSerializerState} state
* @param {PmNode} node
*/
function writeNodeAsHtml(state, node) {
const html = docToHtml({content: [node]});
state.write(html);
state.ensureNewLine();
state.write('\n');
state.closeBlock();
}
/**
* @param {MarkdownSerializerState} state
* @param {PmNode} node
* @param {String} openTag
* @param {String} closeTag
*/
function wrapNodeWithHtml(state, node, openTag, closeTag) {
state.write(openTag);
state.ensureNewLine();
state.renderContent(node);
state.write(closeTag);
state.closeBlock();
state.ensureNewLine();
state.write('\n');
}
// Update serializers to just write out as HTML if we have an attribute
// or element that cannot be represented in commonmark without losing
// formatting or content.
for (const [nodeType, serializerFunction] of Object.entries(nodes)) {
nodes[nodeType] = function (state, node, parent, index) {
if (node.attrs.align || node.attrs.height || node.attrs.width) {
writeNodeAsHtml(state, node);
} else {
serializerFunction(state, node, parent, index);
}
}
}
const serializer = new MarkdownSerializer(nodes, marks);
export default serializer;

View File

@@ -0,0 +1,62 @@
import crel from "crelt"
import {prefix} from "./menu-utils";
import {TextSelection} from "prosemirror-state"
import {expandSelectionToMark} from "../util";
class ColorPickerGrid {
constructor(markType, attrName, colors) {
this.markType = markType;
this.colors = colors
this.attrName = attrName;
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const colorElems = [];
for (const color of this.colors) {
const elem = crel("div", {class: prefix + "-color-grid-item", style: `background-color: ${color};`});
colorElems.push(elem);
}
const wrap = crel("div", {class: prefix + "-color-grid-container"}, colorElems);
wrap.addEventListener('click', event => {
if (event.target.classList.contains(prefix + "-color-grid-item")) {
const color = event.target.style.backgroundColor;
this.onColorSelect(view, color);
}
});
function update(state) {
return true;
}
return {dom: wrap, update}
}
onColorSelect(view, color) {
const attrs = {[this.attrName]: color};
const selection = view.state.selection;
const {from, to} = expandSelectionToMark(view.state, selection, this.markType);
const tr = view.state.tr;
const currentColorMarks = selection.$from.marksAcross(selection.$to) || [];
const activeRelevantMark = currentColorMarks.filter(mark => {
return mark.type === this.markType;
})[0];
const colorIsActive = activeRelevantMark && activeRelevantMark.attrs[this.attrName] === color;
tr.removeMark(from, to, this.markType);
if (!colorIsActive) {
tr.addMark(from, to, this.markType.create(attrs));
}
tr.setSelection(TextSelection.create(tr.doc, from, to));
view.dispatch(tr);
}
}
export default ColorPickerGrid;

View File

@@ -0,0 +1,59 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, renderItems} from "./menu-utils";
import crel from "crelt";
import {getIcon, icons} from "./icons";
class DialogBox {
// :: ([MenuElement], ?Object)
// The following options are recognized:
//
// **`label`**`: string`
// : The label to show on the dialog.
// **`closer`**`: function`
// : The function to run when the dialog should close.
constructor(content, options) {
this.options = options || {};
this.content = Array.isArray(content) ? content : [content];
this.closeMouseDownListener = null;
this.wrap = null;
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const items = renderItems(this.content, view)
const titleText = crel("div", {class: prefix + "-dialog-title-text"}, this.options.label);
const titleClose = crel("button", {class: prefix + "-dialog-title-close primary-background", type: "button"}, getIcon(icons.close));
const titleContent = crel("div", {class: prefix + "-dialog-title"}, titleText, titleClose);
const dialog = crel("div", {class: prefix + "-dialog"}, titleContent,
crel("div", {class: prefix + "-dialog-content"}, items.dom));
const wrap = crel("div", {class: prefix + "-dialog-wrap"}, dialog);
this.wrap = wrap;
this.closeMouseDownListener = (event) => {
if (!dialog.contains(event.target) || titleClose.contains(event.target)) {
this.close();
}
}
wrap.addEventListener("click", this.closeMouseDownListener);
function update(state) {
let inner = items.update(state)
wrap.style.display = inner ? "" : "none"
return inner;
}
return {dom: wrap, update}
}
close() {
if (this.options.closer) {
this.options.closer();
}
}
}
export default DialogBox;

View File

@@ -0,0 +1,51 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, renderItems} from "./menu-utils";
import crel from "crelt";
class DialogForm {
// :: ([MenuElement], ?Object)
// The following options are recognized:
//
// **`action`**`: function(FormData)`
// : The submission action to run when the form is submitted.
// **`canceler`**`: function`
// : The cancel action to run when the form is cancelled.
constructor(content, options) {
this.options = options || {};
this.content = Array.isArray(content) ? content : [content];
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const items = renderItems(this.content, view)
const formButtonCancel = crel("button", {class: prefix + "-dialog-button", type: "button"}, "Cancel");
const formButtonSave = crel("button", {class: prefix + "-dialog-button", type: "submit"}, "Save");
const footer = crel("div", {class: prefix + "-dialog-footer"}, formButtonCancel, formButtonSave);
const form = crel("form", {class: prefix + "-dialog-form", action: '#'}, items.dom, footer);
form.addEventListener('submit', event => {
event.preventDefault();
if (this.options.action) {
this.options.action(new FormData(form));
}
});
formButtonCancel.addEventListener('click', event => {
if (this.options.canceler) {
this.options.canceler();
}
});
function update(state) {
return items.update(state);
}
return {dom: form, update}
}
}
export default DialogForm;

View File

@@ -0,0 +1,42 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, randHtmlId} from "./menu-utils";
import crel from "crelt";
class DialogInput {
// :: (?Object)
// The following options are recognized:
//
// **`label`**`: string`
// : The label to show for the input.
// **`id`**`: string`
// : The id to use for this input
// **`attrs`**`: Object`
// : The attributes to add to the input element.
// **`value`**`: function(state) -> string`
// : The getter for the input value.
constructor(options) {
this.options = options || {};
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const id = randHtmlId();
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
const input = crel("input", inputAttrs);
const label = crel("label", {for: id}, this.options.label);
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, input);
const update = (state) => {
input.value = this.options.value(state);
return true;
}
return {dom: rowRap, update}
}
}
export default DialogInput;

View File

@@ -0,0 +1,53 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, randHtmlId} from "./menu-utils";
import crel from "crelt";
class DialogRadioOptions {
/**
* Given inputOptions should be keyed by label, with values being values.
* Values of empty string will be treated as null.
* @param {Object} inputOptions
* @param {{label: string, id: string, attrs?: Object, value: function(PmEditorState): string|null}} options
*/
constructor(inputOptions, options) {
this.inputOptions = inputOptions;
this.options = options || {};
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const inputs = [];
const optionInputLabels = Object.keys(this.inputOptions).map(label => {
const inputAttrs = Object.assign({
type: "radio",
name: this.options.id,
value: this.inputOptions[label],
class: prefix + '-dialog-radio-option',
}, this.options.attrs || {});
const input = crel("input", inputAttrs);
inputs.push(input);
return crel("label", input, label);
});
const optionInputWrap = crel("div", {class: prefix + '-dialog-radio-option-wrap'}, optionInputLabels);
const label = crel("label", {}, this.options.label);
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, optionInputWrap);
const update = (state) => {
const value = this.options.value(state);
for (const input of inputs) {
input.checked = (input.value === value || (value === null && input.value === ""));
}
return true;
}
return {dom: rowRap, update}
}
}
export default DialogRadioOptions;

View File

@@ -0,0 +1,42 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, randHtmlId} from "./menu-utils";
import crel from "crelt";
class DialogTextArea {
// :: (?Object)
// The following options are recognized:
//
// **`label`**`: string`
// : The label to show for the input.
// **`id`**`: string`
// : The id to use for this input
// **`attrs`**`: Object`
// : The attributes to add to the input element.
// **`value`**`: function(state) -> string`
// : The getter for the input value.
constructor(options) {
this.options = options || {};
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const id = randHtmlId();
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
const input = crel("textarea", inputAttrs);
const label = this.options.label ? crel("label", {for: id}, this.options.label) : null;
const rowRap = crel("div", {class: prefix + '-dialog-textarea-wrap'}, label, input);
const update = (state) => {
input.value = this.options.value(state);
return true;
}
return {dom: rowRap, update}
}
}
export default DialogTextArea;

View File

@@ -0,0 +1,86 @@
import crel from "crelt"
import {prefix} from "./menu-utils";
import {insertTable} from "../commands";
class TableCreatorGrid {
constructor() {
this.size = 10;
this.label = null;
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const gridItems = [];
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
const elem = crel("div", {class: prefix + "-table-creator-grid-item"});
gridItems.push(elem);
elem.addEventListener('mouseenter', event => {
this.updateGridItemActiveStatus(elem, gridItems);
});
}
}
const gridWrap = crel("div", {
class: prefix + "-table-creator-grid",
style: `grid-template-columns: repeat(${this.size}, 14px);`,
}, gridItems);
gridWrap.addEventListener('mouseleave', event => {
this.updateGridItemActiveStatus(null, gridItems);
});
gridWrap.addEventListener('click', event => {
if (event.target.classList.contains(prefix + "-table-creator-grid-item")) {
const {x, y} = this.getPositionOfGridItem(event.target, gridItems);
insertTable(y + 1, x + 1, {
style: 'width: 100%;',
})(view.state, view.dispatch);
}
});
const gridLabel = crel("div", {class: prefix + "-table-creator-grid-label"});
this.label = gridLabel;
const wrap = crel("div", {class: prefix + "-table-creator-grid-container"}, [gridWrap, gridLabel]);
function update(state) {
return true;
}
return {dom: wrap, update}
}
/**
* @param {Element|null} newTarget
* @param {Element[]} gridItems
*/
updateGridItemActiveStatus(newTarget, gridItems) {
const {x: xPos, y: yPos} = this.getPositionOfGridItem(newTarget, gridItems);
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
const active = x <= xPos && y <= yPos;
const index = (y * this.size) + x;
gridItems[index].classList.toggle(prefix + "-table-creator-grid-item-active", active);
}
}
this.label.textContent = (xPos + yPos < 0) ? '' : `${xPos + 1} x ${yPos + 1}`;
}
/**
* @param {Element} gridItem
* @param {Element[]} gridItems
* @return {{x: number, y: number}}
*/
getPositionOfGridItem(gridItem, gridItems) {
const index = gridItems.indexOf(gridItem);
const y = Math.floor(index / this.size);
const x = index % this.size;
return {x, y};
}
}
export default TableCreatorGrid;

View File

@@ -0,0 +1,162 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-menu
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
// :: Object
// A set of basic editor-related icons. Contains the properties
// `join`, `lift`, `selectParentNode`, `undo`, `redo`, `strong`, `em`,
// `code`, `link`, `bulletList`, `orderedList`, and `blockquote`, each
// holding an object that can be used as the `icon` option to
// `MenuItem`.
export const icons = {
undo: {
width: 24, height: 24,
path: "M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"
},
redo: {
width: 24, height: 24,
path: "M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"
},
strong: {
width: 24, height: 24,
path: "M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"
},
em: {
width: 24, height: 24,
path: "M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"
},
link: {
width: 24, height: 24,
path: "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
},
bullet_list: {
width: 24, height: 24,
path: "M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"
},
ordered_list: {
width: 24, height: 24,
path: "M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"
},
task_list: {
width: 24, height: 24,
path: "M22,7h-9v2h9V7z M22,15h-9v2h9V15z M5.54,11L2,7.46l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,11z M5.54,19L2,15.46 l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,19z"
},
underline: {
width: 24, height: 24,
path: "M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"
},
strike: {
width: 24, height: 24,
path: "M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"
},
superscript: {
width: 24, height: 24,
path: "M22,7h-2v1h3v1h-4V7c0-0.55,0.45-1,1-1h2V5h-3V4h3c0.55,0,1,0.45,1,1v1C23,6.55,22.55,7,22,7z M5.88,20h2.66l3.4-5.42h0.12 l3.4,5.42h2.66l-4.65-7.27L17.81,6h-2.68l-3.07,4.99h-0.12L8.85,6H6.19l4.32,6.73L5.88,20z"
},
subscript: {
width: 24, height: 24,
path: "M22,18h-2v1h3v1h-4v-2c0-0.55,0.45-1,1-1h2v-1h-3v-1h3c0.55,0,1,0.45,1,1v1C23,17.55,22.55,18,22,18z M5.88,18h2.66 l3.4-5.42h0.12l3.4,5.42h2.66l-4.65-7.27L17.81,4h-2.68l-3.07,4.99h-0.12L8.85,4H6.19l4.32,6.73L5.88,18z"
},
text_color: {
width: 24, height: 24,
path: "M2,20h20v4H2V20z M5.49,17h2.42l1.27-3.58h5.65L16.09,17h2.42L13.25,3h-2.5L5.49,17z M9.91,11.39l2.03-5.79h0.12l2.03,5.79 H9.91z"
},
background_color: {
width: 24, height: 24,
path: "M16.56,8.94L7.62,0L6.21,1.41l2.38,2.38L3.44,8.94c-0.59,0.59-0.59,1.54,0,2.12l5.5,5.5C9.23,16.85,9.62,17,10,17 s0.77-0.15,1.06-0.44l5.5-5.5C17.15,10.48,17.15,9.53,16.56,8.94z M5.21,10L10,5.21L14.79,10H5.21z M19,11.5c0,0-2,2.17-2,3.5 c0,1.1,0.9,2,2,2s2-0.9,2-2C21,13.67,19,11.5,19,11.5z M2,20h20v4H2V20z"
},
align_left: {
width: 24, height: 24,
path: "M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"
},
align_right: {
width: 24, height: 24,
path: "M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"
},
align_center: {
width: 24, height: 24,
path: "M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"
},
align_justify: {
width: 24, height: 24,
path: "M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z"
},
horizontal_rule: {
width: 24, height: 24,
path: "m 4,11 h 16 v 2 H 4 Z"
},
format_clear: {
width: 24, height: 24,
path: "M3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z"
},
close: {
width: 24, height: 24,
path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
},
source_code: {
width: 24, height: 24,
path: "M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z",
},
table: {
width: 24, height: 24,
path: "M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm0-6H4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4z",
},
iframe: {
width: 24, height: 24,
path: "m 22.71,18.43 c 0.03,-0.29 0.04,-0.58 0.01,-0.86 l 1.07,-0.85 c 0.1,-0.08 0.12,-0.21 0.06,-0.32 L 22.82,14.61 C 22.76,14.5 22.63,14.46 22.51,14.5 L 21.23,15 C 21,14.83 20.75,14.69 20.48,14.58 l -0.2,-1.36 C 20.26,13.09 20.16,13 20.03,13 h -2.07 c -0.12,0 -0.23,0.09 -0.25,0.21 l -0.2,1.36 c -0.26,0.11 -0.51,0.26 -0.74,0.42 l -1.28,-0.5 c -0.12,-0.05 -0.25,0 -0.31,0.11 l -1.03,1.79 c -0.06,0.11 -0.04,0.24 0.06,0.32 l 1.07,0.86 c -0.03,0.29 -0.04,0.58 -0.01,0.86 l -1.07,0.85 c -0.1,0.08 -0.12,0.21 -0.06,0.32 l 1.03,1.79 c 0.06,0.11 0.19,0.15 0.31,0.11 L 16.75,21 c 0.23,0.17 0.48,0.31 0.75,0.42 l 0.2,1.36 c 0.02,0.12 0.12,0.21 0.25,0.21 h 2.07 c 0.12,0 0.23,-0.09 0.25,-0.21 l 0.2,-1.36 c 0.26,-0.11 0.51,-0.26 0.74,-0.42 l 1.28,0.5 c 0.12,0.05 0.25,0 0.31,-0.11 l 1.03,-1.79 c 0.06,-0.11 0.04,-0.24 -0.06,-0.32 z M 19,19.5 c -0.83,0 -1.5,-0.67 -1.5,-1.5 0,-0.83 0.67,-1.5 1.5,-1.5 0.83,0 1.5,0.67 1.5,1.5 0,0.83 -0.67,1.5 -1.5,1.5 z M 15,12 9,8 v 8 z M 3,6 h 18 v 5 h 2 V 6 C 23,4.9 22.1,4 21,4 H 3 C 1.9,4 1,4.9 1,6 v 12 c 0,1.1 0.9,2 2,2 h 9 V 18 H 3 Z",
},
details: {
width: 24, height: 24,
path: "m 7,10 5,5 5,-5 z M 19,2.5 H 5 c -1.11,0 -2,0.9 -2,2 v 14 c 0,1.1 0.89,2 2,2 h 14 c 1.1,0 2,-0.9 2,-2 v -14 c 0,-1.1 -0.89,-2 -2,-2 z m 0,16 H 5 v -12 h 14 z",
}
};
const SVG = "http://www.w3.org/2000/svg"
const XLINK = "http://www.w3.org/1999/xlink"
const prefix = "ProseMirror-icon"
function hashPath(path) {
let hash = 0
for (let i = 0; i < path.length; i++)
hash = (((hash << 5) - hash) + path.charCodeAt(i)) | 0
return hash
}
export function getIcon(icon) {
let node = document.createElement("div")
node.className = prefix
if (icon.path) {
let name = "pm-icon-" + hashPath(icon.path).toString(16)
if (!document.getElementById(name)) buildSVG(name, icon)
let svg = node.appendChild(document.createElementNS(SVG, "svg"))
svg.style.width = (icon.width / icon.height) + "em"
let use = svg.appendChild(document.createElementNS(SVG, "use"))
use.setAttributeNS(XLINK, "href", /([^#]*)/.exec(document.location)[1] + "#" + name)
} else if (icon.dom) {
node.appendChild(icon.dom.cloneNode(true))
} else {
node.appendChild(document.createElement("span")).textContent = icon.text || ''
if (icon.css) node.firstChild.style.cssText = icon.css
}
return node
}
function buildSVG(name, data) {
let collection = document.getElementById(prefix + "-collection")
if (!collection) {
collection = document.createElementNS(SVG, "svg")
collection.id = prefix + "-collection"
collection.style.display = "none"
document.body.insertBefore(collection, document.body.firstChild)
}
let sym = document.createElementNS(SVG, "symbol")
sym.id = name
sym.setAttribute("viewBox", "0 0 " + data.width + " " + data.height)
let path = sym.appendChild(document.createElementNS(SVG, "path"))
path.setAttribute("d", data.path)
collection.appendChild(sym)
}

View File

@@ -0,0 +1,207 @@
import {
MenuItem, Dropdown, DropdownSubmenu, renderGrouped, joinUpItem, liftItem, selectParentNodeItem,
undoItem, redoItem, wrapItem, blockTypeItem, setAttrItem, insertBlockBeforeItem,
} from "./menu"
import {icons} from "./icons";
import ColorPickerGrid from "./ColorPickerGrid";
import TableCreatorGrid from "./TableCreatorGrid";
import {toggleMark} from "prosemirror-commands";
import {menuBar} from "./menubar"
import schema from "../schema";
import {removeMarks} from "../commands";
import itemAnchorButtonItem from "./item-anchor-button";
import itemHtmlSourceButton from "./item-html-source-button";
import itemIframeButton from "./item-iframe-button";
function cmdItem(cmd, options) {
const passedOptions = {
label: options.title,
run: cmd
};
for (const prop in options) {
passedOptions[prop] = options[prop];
}
if ((!options.enable || options.enable === true) && !options.select) {
passedOptions[options.enable ? "enable" : "select"] = function (state) {
return cmd(state);
};
}
return new MenuItem(passedOptions)
}
function markActive(state, type) {
const ref = state.selection;
const from = ref.from;
const $from = ref.$from;
const to = ref.to;
const empty = ref.empty;
if (empty) {
return type.isInSet(state.storedMarks || $from.marks())
} else {
return state.doc.rangeHasMark(from, to, type)
}
}
function markItem(markType, options) {
const passedOptions = {
active: function active(state) {
return markActive(state, markType)
},
enable: true
};
for (const prop in options) {
passedOptions[prop] = options[prop];
}
return cmdItem(toggleMark(markType, passedOptions.attrs), passedOptions)
}
const inlineStyles = [
markItem(schema.marks.strong, {title: "Bold", icon: icons.strong}),
markItem(schema.marks.em, {title: "Italic", icon: icons.em}),
markItem(schema.marks.underline, {title: "Underline", icon: icons.underline}),
markItem(schema.marks.strike, {title: "Strikethrough", icon: icons.strike}),
markItem(schema.marks.superscript, {title: "Superscript", icon: icons.superscript}),
markItem(schema.marks.subscript, {title: "Subscript", icon: icons.subscript}),
];
const formats = [
blockTypeItem(schema.nodes.heading, {
label: "Header Large",
attrs: {level: 2}
}),
blockTypeItem(schema.nodes.heading, {
label: "Header Medium",
attrs: {level: 3}
}),
blockTypeItem(schema.nodes.heading, {
label: "Header Small",
attrs: {level: 4}
}),
blockTypeItem(schema.nodes.heading, {
label: "Header Tiny",
attrs: {level: 5}
}),
blockTypeItem(schema.nodes.paragraph, {
label: "Paragraph",
attrs: {}
}),
markItem(schema.marks.code, {
label: "Inline Code",
attrs: {}
}),
new DropdownSubmenu([
blockTypeItem(schema.nodes.callout, {
label: "Info Callout",
attrs: {type: 'info'}
}),
blockTypeItem(schema.nodes.callout, {
label: "Danger Callout",
attrs: {type: 'danger'}
}),
blockTypeItem(schema.nodes.callout, {
label: "Success Callout",
attrs: {type: 'success'}
}),
blockTypeItem(schema.nodes.callout, {
label: "Warning Callout",
attrs: {type: 'warning'}
})
], { label: 'Callouts' }),
];
const alignments = [
setAttrItem('align', 'left', {
icon: icons.align_left
}),
setAttrItem('align', 'center', {
icon: icons.align_center
}),
setAttrItem('align', 'right', {
icon: icons.align_right
}),
setAttrItem('align', 'justify', {
icon: icons.align_justify
}),
];
const colorOptions = ["#000000","#993300","#333300","#003300","#003366","#000080","#333399","#333333","#800000","#FF6600","#808000","#008000","#008080","#0000FF","#666699","#808080","#FF0000","#FF9900","#99CC00","#339966","#33CCCC","#3366FF","#800080","#999999","#FF00FF","#FFCC00","#FFFF00","#00FF00","#00FFFF","#00CCFF","#993366","#FFFFFF","#FF99CC","#FFCC99","#FFFF99","#CCFFCC","#CCFFFF","#99CCFF","#CC99FF"];
const colors = [
new DropdownSubmenu([
new ColorPickerGrid(schema.marks.text_color, 'color', colorOptions),
], {icon: icons.text_color}),
new DropdownSubmenu([
new ColorPickerGrid(schema.marks.background_color, 'color', colorOptions),
], {icon: icons.background_color}),
];
const lists = [
wrapItem(schema.nodes.bullet_list, {
title: "Bullet List",
icon: icons.bullet_list,
}),
wrapItem(schema.nodes.ordered_list, {
title: "Ordered List",
icon: icons.ordered_list,
}),
];
const inserts = [
itemAnchorButtonItem(),
insertBlockBeforeItem(schema.nodes.horizontal_rule, {
title: "Horizontal Rule",
icon: icons.horizontal_rule,
}),
new DropdownSubmenu([
new TableCreatorGrid()
], {icon: icons.table}),
itemIframeButton(),
wrapItem(schema.nodes.details, {
title: "Dropdown Block",
icon: icons.details,
})
];
const utilities = [
new MenuItem({
title: 'Clear Formatting',
icon: icons.format_clear,
run: removeMarks(),
enable: state => true,
}),
itemHtmlSourceButton(),
];
const menu = menuBar({
floating: false,
content: [
[undoItem, redoItem],
[new DropdownSubmenu(formats, { label: 'Formats' })],
inlineStyles,
colors,
alignments,
lists,
inserts,
utilities,
],
});
export default menu;
// !! This module defines a number of building blocks for ProseMirror
// menus, along with a [menu bar](#menu.menuBar) implementation.
// MenuElement:: interface
// The types defined in this module aren't the only thing you can
// display in your menu. Anything that conforms to this interface can
// be put into a menu structure.
//
// render:: (pm: EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Render the element for display in the menu. Must return a DOM
// element and a function that can be used to update the element to
// a new state. The `update` function will return false if the
// update hid the entire element.

View File

@@ -0,0 +1,120 @@
import DialogBox from "./DialogBox";
import DialogForm from "./DialogForm";
import DialogInput from "./DialogInput";
import DialogRadioOptions from "./DialogRadioOptions";
import schema from "../schema";
import {MenuItem} from "./menu";
import {icons} from "./icons";
import {expandSelectionToMark, nullifyEmptyValues} from "../util";
/**
* @param {PmMarkType} markType
* @param {String} attribute
* @return {(function(PmEditorState): (string|null))}
*/
function getMarkAttribute(markType, attribute) {
return function (state) {
const marks = state.selection.$head.marks();
for (const mark of marks) {
if (mark.type === markType) {
return mark.attrs[attribute];
}
}
return null;
};
}
/**
* @param {(function(FormData))} submitter
* @param {Function} closer
* @return {DialogBox}
*/
function getLinkDialog(submitter, closer) {
return new DialogBox([
new DialogForm([
new DialogInput({
label: 'URL',
id: 'href',
value: getMarkAttribute(schema.marks.link, 'href'),
}),
new DialogInput({
label: 'Hover Label',
id: 'title',
value: getMarkAttribute(schema.marks.link, 'title'),
}),
new DialogRadioOptions({
"Same tab or window": "",
"New tab or window": "_blank",
}, {
label: 'Behaviour',
id: 'target',
value: getMarkAttribute(schema.marks.link, 'target'),
})
], {
canceler: closer,
action: submitter,
}),
], {
label: 'Insert Link',
closer: closer,
});
}
/**
* @param {FormData} formData
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @return {boolean}
*/
function applyLink(formData, state, dispatch) {
const selection = state.selection;
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
if (!dispatch) return true;
const tr = state.tr;
const {from, to} = expandSelectionToMark(state, selection, schema.marks.link);
if (attrs.href) {
tr.addMark(from, to, schema.marks.link.create(attrs));
} else {
tr.removeMark(from, to, schema.marks.link);
}
dispatch(tr);
return true;
}
/**
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @param {PmView} view
* @param {Event} e
*/
function onPress(state, dispatch, view, e) {
const dialog = getLinkDialog((data) => {
applyLink(data, state, dispatch);
dom.remove();
}, () => {
dom.remove();
})
const {dom, update} = dialog.render(view);
update(state);
document.body.appendChild(dom);
}
/**
* @return {MenuItem}
*/
function anchorButtonItem() {
return new MenuItem({
title: "Insert/Edit Anchor Link",
run: onPress,
enable: state => true,
icon: icons.link,
});
}
export default anchorButtonItem;

View File

@@ -0,0 +1,87 @@
import DialogBox from "./DialogBox";
import DialogForm from "./DialogForm";
import DialogTextArea from "./DialogTextArea";
import {MenuItem} from "./menu";
import {icons} from "./icons";
import {htmlToDoc, stateToHtml} from "../util";
/**
* @param {(function(FormData))} submitter
* @param {Function} closer
* @return {DialogBox}
*/
function getLinkDialog(submitter, closer) {
return new DialogBox([
new DialogForm([
new DialogTextArea({
id: 'source',
value: stateToHtml,
attrs: {
rows: 10,
cols: 50,
}
}),
], {
canceler: closer,
action: submitter,
}),
], {
label: 'View/Edit HTML Source',
closer: closer,
});
}
/**
* @param {FormData} formData
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @return {boolean}
*/
function replaceEditorHtml(formData, state, dispatch) {
const html = formData.get('source');
if (dispatch) {
const tr = state.tr;
const newDoc = htmlToDoc(html);
tr.replaceWith(0, state.doc.content.size, newDoc.content);
dispatch(tr);
}
return true;
}
/**
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @param {PmView} view
* @param {Event} e
*/
function onPress(state, dispatch, view, e) {
const dialog = getLinkDialog((data) => {
replaceEditorHtml(data, state, dispatch);
dom.remove();
}, () => {
dom.remove();
})
const {dom, update} = dialog.render(view);
update(state);
document.body.appendChild(dom);
}
/**
* @return {MenuItem}
*/
function htmlSourceButtonItem() {
return new MenuItem({
title: "View HTML Source",
run: onPress,
enable: state => true,
icon: icons.source_code,
});
}
export default htmlSourceButtonItem;

View File

@@ -0,0 +1,115 @@
import DialogBox from "./DialogBox";
import DialogForm from "./DialogForm";
import DialogInput from "./DialogInput";
import schema from "../schema";
import {MenuItem} from "./menu";
import {icons} from "./icons";
import {nullifyEmptyValues} from "../util";
/**
* @param {PmNodeType} nodeType
* @param {String} attribute
* @return {(function(PmEditorState): (string|null))}
*/
function getNodeAttribute(nodeType, attribute) {
return function (state) {
const node = state.selection.node;
if (node && node.type === nodeType) {
return node.attrs[attribute];
}
return null;
};
}
/**
* @param {(function(FormData))} submitter
* @param {Function} closer
* @return {DialogBox}
*/
function getLinkDialog(submitter, closer) {
return new DialogBox([
new DialogForm([
new DialogInput({
label: 'Source URL',
id: 'src',
value: getNodeAttribute(schema.nodes.iframe, 'src'),
}),
new DialogInput({
label: 'Hover Label',
id: 'title',
value: getNodeAttribute(schema.nodes.iframe, 'title'),
}),
new DialogInput({
label: 'Width',
id: 'width',
value: getNodeAttribute(schema.nodes.iframe, 'width'),
}),
new DialogInput({
label: 'Height',
id: 'height',
value: getNodeAttribute(schema.nodes.iframe, 'height'),
}),
], {
canceler: closer,
action: submitter,
}),
], {
label: 'Insert Embedded Content',
closer: closer,
});
}
/**
* @param {FormData} formData
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @return {boolean}
*/
function applyIframe(formData, state, dispatch) {
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
if (!dispatch) return true;
const tr = state.tr;
const currentNodeAttrs = state.selection?.nodes?.attrs || {};
const newAttrs = Object.assign({}, currentNodeAttrs, attrs);
tr.replaceSelectionWith(schema.nodes.iframe.create(newAttrs));
dispatch(tr);
return true;
}
/**
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @param {PmView} view
* @param {Event} e
*/
function onPress(state, dispatch, view, e) {
const dialog = getLinkDialog((data) => {
applyIframe(data, state, dispatch);
dom.remove();
}, () => {
dom.remove();
})
const {dom, update} = dialog.render(view);
update(state);
document.body.appendChild(dom);
}
/**
* @return {MenuItem}
*/
function iframeButtonItem() {
return new MenuItem({
title: "Embed Content",
run: onPress,
enable: state => true,
active: state => (state.selection.node || {type: ''}).type === schema.nodes.iframe,
icon: icons.iframe,
});
}
export default iframeButtonItem;

View File

@@ -0,0 +1,39 @@
import crel from "crelt";
export const prefix = "ProseMirror-menu";
export function renderDropdownItems(items, view) {
let rendered = [], updates = []
for (let i = 0; i < items.length; i++) {
let {dom, update} = items[i].render(view)
rendered.push(crel("div", {class: prefix + "-dropdown-item"}, dom))
updates.push(update)
}
return {dom: rendered, update: combineUpdates(updates, rendered)}
}
export function renderItems(items, view) {
let rendered = [], updates = []
for (let i = 0; i < items.length; i++) {
let {dom, update} = items[i].render(view)
rendered.push(dom);
updates.push(update)
}
return {dom: rendered, update: combineUpdates(updates, rendered)}
}
export function combineUpdates(updates, nodes) {
return state => {
let something = false
for (let i = 0; i < updates.length; i++) {
let up = updates[i](state)
nodes[i].style.display = up ? "" : "none"
if (up) something = true
}
return something
}
}
export function randHtmlId() {
return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 9);
}

View File

@@ -0,0 +1,419 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-menu
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
import crel from "crelt"
import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands"
import {undo, redo} from "prosemirror-history"
import {setBlockAttr, insertBlockBefore} from "../commands";
import {renderDropdownItems, combineUpdates} from "./menu-utils";
import {getIcon, icons} from "./icons"
import {prefix} from "./menu-utils";
// ::- An icon or label that, when clicked, executes a command.
export class MenuItem {
// :: (MenuItemSpec)
constructor(spec) {
// :: MenuItemSpec
// The spec used to create the menu item.
this.spec = spec
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the icon according to its [display
// spec](#menu.MenuItemSpec.display), and adds an event handler which
// executes the command when the representation is clicked.
render(view) {
let spec = this.spec
let dom = spec.render ? spec.render(view)
: spec.icon ? getIcon(spec.icon)
: spec.label ? crel("div", null, translate(view, spec.label))
: null
if (!dom) throw new RangeError("MenuItem without icon or label property")
if (spec.title) {
const title = (typeof spec.title === "function" ? spec.title(view.state) : spec.title)
dom.setAttribute("title", translate(view, title))
}
if (spec.class) dom.classList.add(spec.class)
if (spec.css) dom.style.cssText += spec.css
dom.addEventListener("mousedown", e => {
e.preventDefault()
if (!dom.classList.contains(prefix + "-disabled"))
spec.run(view.state, view.dispatch, view, e)
})
function update(state) {
if (spec.select) {
let selected = spec.select(state)
dom.style.display = selected ? "" : "none"
if (!selected) return false
}
let enabled = true
if (spec.enable) {
enabled = spec.enable(state) || false
setClass(dom, prefix + "-disabled", !enabled)
}
if (spec.active) {
let active = enabled && spec.active(state) || false
setClass(dom, prefix + "-active", active)
}
return true
}
return {dom, update}
}
}
function translate(view, text) {
return view._props.translate ? view._props.translate(text) : text
}
// MenuItemSpec:: interface
// The configuration object passed to the `MenuItem` constructor.
//
// run:: (EditorState, (Transaction), EditorView, dom.Event)
// The function to execute when the menu item is activated.
//
// select:: ?(EditorState) → bool
// Optional function that is used to determine whether the item is
// appropriate at the moment. Deselected items will be hidden.
//
// enable:: ?(EditorState) → bool
// Function that is used to determine if the item is enabled. If
// given and returning false, the item will be given a disabled
// styling.
//
// active:: ?(EditorState) → bool
// A predicate function to determine whether the item is 'active' (for
// example, the item for toggling the strong mark might be active then
// the cursor is in strong text).
//
// render:: ?(EditorView) → dom.Node
// A function that renders the item. You must provide either this,
// [`icon`](#menu.MenuItemSpec.icon), or [`label`](#MenuItemSpec.label).
//
// icon:: ?Object
// Describes an icon to show for this item. The object may specify
// an SVG icon, in which case its `path` property should be an [SVG
// path
// spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d),
// and `width` and `height` should provide the viewbox in which that
// path exists. Alternatively, it may have a `text` property
// specifying a string of text that makes up the icon, with an
// optional `css` property giving additional CSS styling for the
// text. _Or_ it may contain `dom` property containing a DOM node.
//
// label:: ?string
// Makes the item show up as a text label. Mostly useful for items
// wrapped in a [drop-down](#menu.Dropdown) or similar menu. The object
// should have a `label` property providing the text to display.
//
// title:: ?union<string, (EditorState) → string>
// Defines DOM title (mouseover) text for the item.
//
// class:: ?string
// Optionally adds a CSS class to the item's DOM representation.
//
// css:: ?string
// Optionally adds a string of inline CSS to the item's DOM
// representation.
let lastMenuEvent = {time: 0, node: null}
function markMenuEvent(e) {
lastMenuEvent.time = Date.now()
lastMenuEvent.node = e.target
}
function isMenuEvent(wrapper) {
return Date.now() - 100 < lastMenuEvent.time &&
lastMenuEvent.node && wrapper.contains(lastMenuEvent.node)
}
// ::- A drop-down menu, displayed as a label with a downwards-pointing
// triangle to the right of it.
export class Dropdown {
// :: ([MenuElement], ?Object)
// Create a dropdown wrapping the elements. Options may include
// the following properties:
//
// **`label`**`: string`
// : The label to show on the drop-down control.
//
// **`title`**`: string`
// : Sets the
// [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title)
// attribute given to the menu control.
//
// **`class`**`: string`
// : When given, adds an extra CSS class to the menu control.
//
// **`css`**`: string`
// : When given, adds an extra set of CSS styles to the menu control.
constructor(content, options) {
this.options = options || {}
this.content = Array.isArray(content) ? content : [content]
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState)}
// Render the dropdown menu and sub-items.
render(view) {
let content = renderDropdownItems(this.content, view)
let label = crel("div", {class: prefix + "-dropdown " + (this.options.class || ""),
style: this.options.css},
translate(view, this.options.label))
if (this.options.title) label.setAttribute("title", translate(view, this.options.title))
let wrap = crel("div", {class: prefix + "-dropdown-wrap"}, label)
let open = null, listeningOnClose = null
let close = () => {
if (open && open.close()) {
open = null
window.removeEventListener("mousedown", listeningOnClose)
}
}
label.addEventListener("mousedown", e => {
e.preventDefault()
markMenuEvent(e)
if (open) {
close()
} else {
open = this.expand(wrap, content.dom)
window.addEventListener("mousedown", listeningOnClose = () => {
if (!isMenuEvent(wrap)) close()
})
}
})
function update(state) {
let inner = content.update(state)
wrap.style.display = inner ? "" : "none"
return inner
}
return {dom: wrap, update}
}
expand(dom, items) {
let menuDOM = crel("div", {class: prefix + "-dropdown-menu " + (this.options.class || "")}, items)
let done = false
function close() {
if (done) return
done = true
dom.removeChild(menuDOM)
return true
}
dom.appendChild(menuDOM)
return {close, node: menuDOM}
}
}
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
export class DropdownSubmenu {
// :: ([MenuElement], ?Object)
// Creates a submenu for the given group of menu elements. The
// following options are recognized:
//
// **`label`**`: string`
// : The label to show on the submenu.
constructor(content, options) {
this.options = options || {}
this.content = Array.isArray(content) ? content : [content]
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const items = renderDropdownItems(this.content, view)
const handleContent = this.options.icon ? getIcon(this.options.icon) : crel("div", {class: prefix + "-submenu-label"}, translate(view, this.options.label));
const wrap = crel("div", {class: prefix + "-submenu-wrap"}, handleContent,
crel("div", {class: prefix + "-submenu"}, items.dom))
let listeningOnClose = null
handleContent.addEventListener("mousedown", e => {
e.preventDefault()
markMenuEvent(e)
setClass(wrap, prefix + "-submenu-wrap-active")
if (!listeningOnClose)
window.addEventListener("mousedown", listeningOnClose = () => {
if (!isMenuEvent(wrap)) {
wrap.classList.remove(prefix + "-submenu-wrap-active")
window.removeEventListener("mousedown", listeningOnClose)
listeningOnClose = null
}
})
})
function update(state) {
let inner = items.update(state)
wrap.style.display = inner ? "" : "none"
return inner
}
return {dom: wrap, update}
}
}
// :: (EditorView, [[MenuElement]]) → {dom: dom.DocumentFragment, update: (EditorState) → bool}
// Render the given, possibly nested, array of menu elements into a
// document fragment, placing separators between them (and ensuring no
// superfluous separators appear when some of the groups turn out to
// be empty).
export function renderGrouped(view, content) {
let result = document.createDocumentFragment()
let updates = [], separators = []
for (let i = 0; i < content.length; i++) {
let items = content[i], localUpdates = [], localNodes = []
for (let j = 0; j < items.length; j++) {
let {dom, update} = items[j].render(view)
let span = crel("span", {class: prefix + "item"}, dom)
result.appendChild(span)
localNodes.push(span)
localUpdates.push(update)
}
if (localUpdates.length) {
updates.push(combineUpdates(localUpdates, localNodes))
if (i < content.length - 1)
separators.push(result.appendChild(separator()))
}
}
function update(state) {
let something = false, needSep = false
for (let i = 0; i < updates.length; i++) {
let hasContent = updates[i](state)
if (i) separators[i - 1].style.display = needSep && hasContent ? "" : "none"
needSep = hasContent
if (hasContent) something = true
}
return something
}
return {dom: result, update}
}
function separator() {
return crel("span", {class: prefix + "separator"})
}
// :: MenuItem
// Menu item for the `joinUp` command.
export const joinUpItem = new MenuItem({
title: "Join with above block",
run: joinUp,
select: state => joinUp(state),
icon: icons.join
})
// :: MenuItem
// Menu item for the `lift` command.
export const liftItem = new MenuItem({
title: "Lift out of enclosing block",
run: lift,
select: state => lift(state),
icon: icons.lift
})
// :: MenuItem
// Menu item for the `selectParentNode` command.
export const selectParentNodeItem = new MenuItem({
title: "Select parent node",
run: selectParentNode,
select: state => selectParentNode(state),
icon: icons.selectParentNode
})
// :: MenuItem
// Menu item for the `undo` command.
export let undoItem = new MenuItem({
title: "Undo last change",
run: undo,
enable: state => undo(state),
icon: icons.undo
})
// :: MenuItem
// Menu item for the `redo` command.
export let redoItem = new MenuItem({
title: "Redo last undone change",
run: redo,
enable: state => redo(state),
icon: icons.redo
})
// :: (NodeType, Object) → MenuItem
// Build a menu item for wrapping the selection in a given node type.
// Adds `run` and `select` properties to the ones present in
// `options`. `options.attrs` may be an object or a function.
export function wrapItem(nodeType, options) {
let passedOptions = {
run(state, dispatch) {
// FIXME if (options.attrs instanceof Function) options.attrs(state, attrs => wrapIn(nodeType, attrs)(state))
return wrapIn(nodeType, options.attrs)(state, dispatch)
},
select(state) {
return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
}
}
for (let prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions)
}
// :: (NodeType, Object) → MenuItem
// Build a menu item for changing the type of the textblock around the
// selection to the given type. Provides `run`, `active`, and `select`
// properties. Others must be given in `options`. `options.attrs` may
// be an object to provide the attributes for the textblock node.
export function blockTypeItem(nodeType, options) {
let command = setBlockType(nodeType, options.attrs)
let passedOptions = {
run: command,
enable(state) { return command(state) },
active(state) {
let {$from, to, node} = state.selection
if (node) return node.hasMarkup(nodeType, options.attrs)
return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs)
}
}
for (let prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions)
}
export function setAttrItem(attrName, attrValue, options) {
const command = setBlockAttr(attrName, attrValue);
const passedOptions = {
run: command,
enable(state) { return command(state) },
active(state) {
const {$from, to, node} = state.selection
if (node) return node.attrs[attrValue] === attrValue;
return to <= $from.end() && $from.parent.attrs[attrValue] === attrValue;
}
}
for (const prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions)
}
export function insertBlockBeforeItem(blockType, options) {
const command = insertBlockBefore(blockType);
const passedOptions = {
run: command,
enable(state) { return command(state) },
active(state) {
return false;
}
}
for (const prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions);
}
// Work around classList.toggle being broken in IE11
function setClass(dom, cls, on) {
if (on) dom.classList.add(cls)
else dom.classList.remove(cls)
}

View File

@@ -0,0 +1,163 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-menu
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
import crel from "crelt"
import {Plugin} from "prosemirror-state"
import {renderGrouped} from "./menu"
const prefix = "ProseMirror-menubar"
function isIOS() {
if (typeof navigator == "undefined") return false
let agent = navigator.userAgent
return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent)
}
// :: (Object) → Plugin
// A plugin that will place a menu bar above the editor. Note that
// this involves wrapping the editor in an additional `<div>`.
//
// options::-
// Supports the following options:
//
// content:: [[MenuElement]]
// Provides the content of the menu, as a nested array to be
// passed to `renderGrouped`.
//
// floating:: ?bool
// Determines whether the menu floats, i.e. whether it sticks to
// the top of the viewport when the editor is partially scrolled
// out of view.
export function menuBar(options) {
return new Plugin({
view(editorView) { return new MenuBarView(editorView, options) }
})
}
class MenuBarView {
constructor(editorView, options) {
this.editorView = editorView
this.options = options
this.wrapper = crel("div", {class: prefix + "-wrapper"})
this.menu = this.wrapper.appendChild(crel("div", {class: prefix}))
this.menu.className = prefix
this.spacer = null
if (editorView.dom.parentNode)
editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom)
this.wrapper.appendChild(editorView.dom)
this.maxHeight = 0
this.widthForMaxHeight = 0
this.floating = false
let {dom, update} = renderGrouped(this.editorView, this.options.content)
this.contentUpdate = update
this.menu.appendChild(dom)
this.update()
if (options.floating && !isIOS()) {
this.updateFloat()
let potentialScrollers = getAllWrapping(this.wrapper)
this.scrollFunc = (e) => {
let root = this.editorView.root
if (!(root.body || root).contains(this.wrapper)) {
potentialScrollers.forEach(el => el.removeEventListener("scroll", this.scrollFunc))
} else {
this.updateFloat(e.target.getBoundingClientRect && e.target)
}
}
potentialScrollers.forEach(el => el.addEventListener('scroll', this.scrollFunc))
}
}
update() {
this.contentUpdate(this.editorView.state)
if (this.floating) {
this.updateScrollCursor()
} else {
if (this.menu.offsetWidth != this.widthForMaxHeight) {
this.widthForMaxHeight = this.menu.offsetWidth
this.maxHeight = 0
}
if (this.menu.offsetHeight > this.maxHeight) {
this.maxHeight = this.menu.offsetHeight
this.menu.style.minHeight = this.maxHeight + "px"
}
}
}
updateScrollCursor() {
let selection = this.editorView.root.getSelection()
if (!selection.focusNode) return
let rects = selection.getRangeAt(0).getClientRects()
let selRect = rects[selectionIsInverted(selection) ? 0 : rects.length - 1]
if (!selRect) return
let menuRect = this.menu.getBoundingClientRect()
if (selRect.top < menuRect.bottom && selRect.bottom > menuRect.top) {
let scrollable = findWrappingScrollable(this.wrapper)
if (scrollable) scrollable.scrollTop -= (menuRect.bottom - selRect.top)
}
}
updateFloat(scrollAncestor) {
let parent = this.wrapper, editorRect = parent.getBoundingClientRect(),
top = scrollAncestor ? Math.max(0, scrollAncestor.getBoundingClientRect().top) : 0
if (this.floating) {
if (editorRect.top >= top || editorRect.bottom < this.menu.offsetHeight + 10) {
this.floating = false
this.menu.style.position = this.menu.style.left = this.menu.style.top = this.menu.style.width = ""
this.menu.style.display = ""
this.spacer.parentNode.removeChild(this.spacer)
this.spacer = null
} else {
let border = (parent.offsetWidth - parent.clientWidth) / 2
this.menu.style.left = (editorRect.left + border) + "px"
this.menu.style.display = (editorRect.top > window.innerHeight ? "none" : "")
if (scrollAncestor) this.menu.style.top = top + "px"
}
} else {
if (editorRect.top < top && editorRect.bottom >= this.menu.offsetHeight + 10) {
this.floating = true
let menuRect = this.menu.getBoundingClientRect()
this.menu.style.left = menuRect.left + "px"
this.menu.style.width = menuRect.width + "px"
if (scrollAncestor) this.menu.style.top = top + "px"
this.menu.style.position = "fixed"
this.spacer = crel("div", {class: prefix + "-spacer", style: `height: ${menuRect.height}px`})
parent.insertBefore(this.spacer, this.menu)
}
}
}
destroy() {
if (this.wrapper.parentNode)
this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper)
}
}
// Not precise, but close enough
function selectionIsInverted(selection) {
if (selection.anchorNode == selection.focusNode) return selection.anchorOffset > selection.focusOffset
return selection.anchorNode.compareDocumentPosition(selection.focusNode) == Node.DOCUMENT_POSITION_FOLLOWING
}
function findWrappingScrollable(node) {
for (let cur = node.parentNode; cur; cur = cur.parentNode)
if (cur.scrollHeight > cur.clientHeight) return cur
}
function getAllWrapping(node) {
let res = [window]
for (let cur = node.parentNode; cur; cur = cur.parentNode)
res.push(cur)
return res
}

View File

@@ -0,0 +1,26 @@
class IframeView {
/**
* @param {PmNode} node
* @param {PmView} view
* @param {(function(): number)} getPos
*/
constructor(node, view, getPos) {
this.dom = document.createElement('div');
this.dom.classList.add('ProseMirror-iframewrap');
this.iframe = document.createElement("iframe");
for (const [key, value] of Object.entries(node.attrs)) {
if (value) {
this.iframe.setAttribute(key, value);
}
}
this.dom.appendChild(this.iframe);
}
stopEvent() {
return false;
}
}
export default IframeView;

View File

@@ -0,0 +1,197 @@
import {positionHandlesAtCorners, removeHandles, renderHandlesAtCorners} from "./node-view-utils";
import {NodeSelection} from "prosemirror-state";
class ImageView {
/**
* @param {PmNode} node
* @param {PmView} view
* @param {(function(): number)} getPos
*/
constructor(node, view, getPos) {
this.dom = document.createElement('div');
this.dom.classList.add('ProseMirror-imagewrap');
this.image = document.createElement("img");
this.image.src = node.attrs.src;
this.image.alt = node.attrs.alt;
if (node.attrs.width) {
this.image.width = node.attrs.width;
}
if (node.attrs.height) {
this.image.height = node.attrs.height;
}
this.dom.appendChild(this.image);
this.handles = [];
this.handleDragStartInfo = null;
this.handleDragMoveDimensions = null;
this.removeHandlesListener = this.removeHandlesListener.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.dom.addEventListener("click", event => {
this.showHandles();
});
// Show handles if selected
if (view.state.selection.node === node) {
window.setTimeout(() => {
this.showHandles();
}, 10);
}
this.updateImageDimensions = function (width, height) {
const attrs = Object.assign({}, node.attrs, {width, height});
let tr = view.state.tr;
const position = getPos();
tr = tr.setNodeMarkup(position, null, attrs)
tr = tr.setSelection(NodeSelection.create(tr.doc, position));
view.dispatch(tr);
};
}
showHandles() {
if (this.handles.length === 0) {
this.image.dataset.showHandles = 'true';
window.addEventListener('click', this.removeHandlesListener);
this.handles = renderHandlesAtCorners(this.image);
for (const handle of this.handles) {
handle.addEventListener('mousedown', this.handleMouseDown);
}
}
}
removeHandlesListener(event) {
if (!this.dom.contains(event.target)) {
this.removeHandles();
this.handles = [];
}
}
removeHandles() {
removeHandles(this.handles);
window.removeEventListener('click', this.removeHandlesListener);
delete this.image.dataset.showHandles;
}
stopEvent() {
return false;
}
/**
* @param {MouseEvent} event
*/
handleMouseDown(event) {
event.preventDefault();
const imageBounds = this.image.getBoundingClientRect();
const handle = event.target;
this.handleDragStartInfo = {
x: event.screenX,
y: event.screenY,
ratio: imageBounds.width / imageBounds.height,
bounds: imageBounds,
handleX: handle.dataset.x,
handleY: handle.dataset.y,
};
this.createDragDummy(imageBounds);
this.dom.appendChild(this.dragDummy);
window.addEventListener('mousemove', this.handleMouseMove);
window.addEventListener('mouseup', this.handleMouseUp);
}
/**
* @param {DOMRect} bounds
*/
createDragDummy(bounds) {
this.dragDummy = this.image.cloneNode();
this.dragDummy.style.opacity = '0.5';
this.dragDummy.classList.add('ProseMirror-dragdummy');
this.dragDummy.style.width = bounds.width + 'px';
this.dragDummy.style.height = bounds.height + 'px';
}
/**
* @param {MouseEvent} event
*/
handleMouseUp(event) {
if (this.handleDragMoveDimensions) {
const {width, height} = this.handleDragMoveDimensions;
this.updateImageDimensions(String(width), String(height));
}
window.removeEventListener('mousemove', this.handleMouseMove);
window.removeEventListener('mouseup', this.handleMouseUp);
this.handleDragStartInfo = null;
this.handleDragMoveDimensions = null;
this.dragDummy.remove();
positionHandlesAtCorners(this.image, this.handles);
}
/**
* @param {MouseEvent} event
*/
handleMouseMove(event) {
const originalBounds = this.handleDragStartInfo.bounds;
// Calculate change in x & y, flip amounts depending on handle
let xChange = event.screenX - this.handleDragStartInfo.x;
if (this.handleDragStartInfo.handleX === 'left') {
xChange = -xChange;
}
let yChange = event.screenY - this.handleDragStartInfo.y;
if (this.handleDragStartInfo.handleY === 'top') {
yChange = -yChange;
}
// Prevent images going too small or into negative bounds
if (originalBounds.width + xChange < 10) {
xChange = -originalBounds.width + 10;
}
if (originalBounds.height + yChange < 10) {
yChange = -originalBounds.height + 10;
}
// Choose the larger dimension change and align the other to keep
// image aspect ratio, aligning growth/reduction direction
if (Math.abs(xChange) > Math.abs(yChange)) {
yChange = Math.floor(xChange * this.handleDragStartInfo.ratio);
if (yChange * xChange < 0) {
yChange = -yChange;
}
} else {
xChange = Math.floor(yChange / this.handleDragStartInfo.ratio);
if (xChange * yChange < 0) {
xChange = -xChange;
}
}
// Calculate our new sizes
const newWidth = originalBounds.width + xChange;
const newHeight = originalBounds.height + yChange;
// Apply the sizes and positioning to our ghost dummy
this.dragDummy.style.width = `${newWidth}px`;
if (this.handleDragStartInfo.handleX === 'left') {
this.dragDummy.style.left = `${-xChange}px`;
}
this.dragDummy.style.height = `${newHeight}px`;
if (this.handleDragStartInfo.handleY === 'top') {
this.dragDummy.style.top = `${-yChange}px`;
}
// Update corners and track dimension changes for later application
positionHandlesAtCorners(this.dragDummy, this.handles);
this.handleDragMoveDimensions = {
width: newWidth,
height: newHeight,
}
}
}
export default ImageView;

View File

@@ -0,0 +1,21 @@
class TableView {
/**
* @param {PmNode} node
* @param {PmView} view
* @param {(function(): number)} getPos
*/
constructor(node, view, getPos) {
this.dom = document.createElement("div")
this.dom.className = "ProseMirror-tableWrapper"
this.table = this.dom.appendChild(document.createElement("table"));
this.table.setAttribute('style', node.attrs.style);
this.colgroup = this.table.appendChild(document.createElement("colgroup"));
this.contentDOM = this.table.appendChild(document.createElement("tbody"));
}
ignoreMutation(record) {
return record.type == "attributes" && (record.target == this.table || this.colgroup.contains(record.target))
}
}
export default TableView;

View File

@@ -0,0 +1,11 @@
import ImageView from "./ImageView";
import IframeView from "./IframeView";
import TableView from "./TableView";
const views = {
image: (node, view, getPos) => new ImageView(node, view, getPos),
iframe: (node, view, getPos) => new IframeView(node, view, getPos),
table: (node, view, getPos) => new TableView(node, view, getPos),
};
export default views;

View File

@@ -0,0 +1,58 @@
import crel from "crelt";
/**
* Render grab handles at the corners of the given element.
* @param {Element} elem
* @return {Element[]}
*/
export function renderHandlesAtCorners(elem) {
const handles = [];
const baseClass = 'ProseMirror-grabhandle';
for (let i = 0; i < 4; i++) {
const y = (i < 2) ? 'top' : 'bottom';
const x = (i === 0 || i === 3) ? 'left' : 'right';
const handle = crel('div', {
class: `${baseClass} ${baseClass}-${x}-${y}`,
});
handle.dataset.y = y;
handle.dataset.x = x;
handles.push(handle);
elem.parentNode.appendChild(handle);
}
positionHandlesAtCorners(elem, handles);
return handles;
}
/**
* @param {Element[]} handles
*/
export function removeHandles(handles) {
for (const handle of handles) {
handle.remove();
}
}
/**
*
* @param {Element} element
* @param {[Element, Element, Element, Element]}handles
*/
export function positionHandlesAtCorners(element, handles) {
const bounds = element.getBoundingClientRect();
const parentBounds = element.parentElement.getBoundingClientRect();
const positions = [
{x: bounds.left - parentBounds.left, y: bounds.top - parentBounds.top},
{x: bounds.right - parentBounds.left, y: bounds.top - parentBounds.top},
{x: bounds.right - parentBounds.left, y: bounds.bottom - parentBounds.top},
{x: bounds.left - parentBounds.left, y: bounds.bottom - parentBounds.top},
];
for (let i = 0; i < 4; i++) {
const {x, y} = positions[i];
const handle = handles[i];
handle.style.left = (x - 6) + 'px';
handle.style.top = (y - 6) + 'px';
}
}

View File

@@ -0,0 +1,288 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-tables
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
import {Plugin, PluginKey} from "prosemirror-state"
import {Decoration, DecorationSet} from "prosemirror-view"
import {
cellAround,
pointsAtCell,
setAttr,
TableMap,
} from "prosemirror-tables";
export const key = new PluginKey("tableColumnResizing")
export function columnResizing(options = {}) {
const {
handleWidth, cellMinWidth, lastColumnResizable
} = Object.assign({
handleWidth: 5,
cellMinWidth: 25,
lastColumnResizable: true
}, options);
let plugin = new Plugin({
key,
state: {
init(_, state) {
return new ResizeState(-1, false)
},
apply(tr, prev) {
return prev.apply(tr)
}
},
props: {
attributes(state) {
let pluginState = key.getState(state)
return pluginState.activeHandle > -1 ? {class: "resize-cursor"} : null
},
handleDOMEvents: {
mousemove(view, event) {
handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable)
},
mouseleave(view) {
handleMouseLeave(view)
},
mousedown(view, event) {
handleMouseDown(view, event, cellMinWidth)
}
},
decorations(state) {
let pluginState = key.getState(state)
if (pluginState.activeHandle > -1) return handleDecorations(state, pluginState.activeHandle)
},
nodeViews: {}
}
})
return plugin
}
class ResizeState {
constructor(activeHandle, dragging) {
this.activeHandle = activeHandle
this.dragging = dragging
}
apply(tr) {
let state = this, action = tr.getMeta(key)
if (action && action.setHandle != null)
return new ResizeState(action.setHandle, null)
if (action && action.setDragging !== undefined)
return new ResizeState(state.activeHandle, action.setDragging)
if (state.activeHandle > -1 && tr.docChanged) {
let handle = tr.mapping.map(state.activeHandle, -1)
if (!pointsAtCell(tr.doc.resolve(handle))) handle = null
state = new ResizeState(handle, state.dragging)
}
return state
}
}
function handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable) {
let pluginState = key.getState(view.state)
if (!pluginState.dragging) {
let target = domCellAround(event.target), cell = -1
if (target) {
let {left, right} = target.getBoundingClientRect()
if (event.clientX - left <= handleWidth)
cell = edgeCell(view, event, "left")
else if (right - event.clientX <= handleWidth)
cell = edgeCell(view, event, "right")
}
if (cell != pluginState.activeHandle) {
if (!lastColumnResizable && cell !== -1) {
let $cell = view.state.doc.resolve(cell)
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
if (col == map.width - 1) {
return
}
}
updateHandle(view, cell)
}
}
}
function handleMouseLeave(view) {
let pluginState = key.getState(view.state)
if (pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1)
}
function handleMouseDown(view, event, cellMinWidth) {
let pluginState = key.getState(view.state)
if (pluginState.activeHandle == -1 || pluginState.dragging) return false
let cell = view.state.doc.nodeAt(pluginState.activeHandle)
let width = currentColWidth(view, pluginState.activeHandle, cell.attrs)
view.dispatch(view.state.tr.setMeta(key, {setDragging: {startX: event.clientX, startWidth: width}}))
function finish(event) {
window.removeEventListener("mouseup", finish)
window.removeEventListener("mousemove", move)
let pluginState = key.getState(view.state)
if (pluginState.dragging) {
updateColumnWidth(view, pluginState.activeHandle, draggedWidth(pluginState.dragging, event, cellMinWidth))
view.dispatch(view.state.tr.setMeta(key, {setDragging: null}))
}
}
function move(event) {
if (!event.which) return finish(event)
let pluginState = key.getState(view.state)
let dragged = draggedWidth(pluginState.dragging, event, cellMinWidth)
displayColumnWidth(view, pluginState.activeHandle, dragged, cellMinWidth)
}
window.addEventListener("mouseup", finish)
window.addEventListener("mousemove", move)
event.preventDefault()
return true
}
function currentColWidth(view, cellPos, {colspan, colwidth}) {
let width = colwidth && colwidth[colwidth.length - 1]
if (width) return width
let dom = view.domAtPos(cellPos)
let node = dom.node.childNodes[dom.offset]
let domWidth = node.offsetWidth, parts = colspan
if (colwidth) for (let i = 0; i < colspan; i++) if (colwidth[i]) {
domWidth -= colwidth[i]
parts--
}
return domWidth / parts
}
function domCellAround(target) {
while (target && target.nodeName != "TD" && target.nodeName != "TH")
target = target.classList.contains("ProseMirror") ? null : target.parentNode
return target
}
function edgeCell(view, event, side) {
let found = view.posAtCoords({left: event.clientX, top: event.clientY})
if (!found) return -1
let {pos} = found
let $cell = cellAround(view.state.doc.resolve(pos))
if (!$cell) return -1
if (side == "right") return $cell.pos
let map = TableMap.get($cell.node(-1)), start = $cell.start(-1)
let index = map.map.indexOf($cell.pos - start)
return index % map.width == 0 ? -1 : start + map.map[index - 1]
}
function draggedWidth(dragging, event, cellMinWidth) {
let offset = event.clientX - dragging.startX
return Math.max(cellMinWidth, dragging.startWidth + offset)
}
function updateHandle(view, value) {
view.dispatch(view.state.tr.setMeta(key, {setHandle: value}))
}
function updateColumnWidth(view, cell, width) {
let $cell = view.state.doc.resolve(cell);
let table = $cell.node(-1);
let map = TableMap.get(table);
let start = $cell.start(-1);
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
let tr = view.state.tr;
for (let row = 0; row < map.height; row++) {
let mapIndex = row * map.width + col;
// Rowspanning cell that has already been handled
if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue
let pos = map.map[mapIndex]
let {attrs} = table.nodeAt(pos);
const newWidth = (attrs.colspan * width) + 'px';
tr.setNodeMarkup(start + pos, null, setAttr(attrs, "width", newWidth));
}
if (tr.docChanged) view.dispatch(tr)
}
function displayColumnWidth(view, cell, width, cellMinWidth) {
const $cell = view.state.doc.resolve(cell)
const table = $cell.node(-1);
const start = $cell.start(-1);
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
let dom = view.domAtPos($cell.start(-1)).node
while (dom.nodeName !== "TABLE") {
dom = dom.parentNode
}
updateColumnsOnResize(view, table, dom, cellMinWidth, col, width)
}
function updateColumnsOnResize(view, tableNode, tableDom, cellMinWidth, overrideCol, overrideValue) {
console.log({tableNode, tableDom, cellMinWidth, overrideCol, overrideValue});
let totalWidth = 0;
let fixedWidth = true;
const rows = tableDom.querySelectorAll('tr');
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cell = row.children[overrideCol];
cell.style.width = `${overrideValue}px`;
if (y === 0) {
for (let x = 0; x < row.children.length; x++) {
const cell = row.children[x];
if (cell.style.width) {
const width = Number(cell.style.width.replace('px', ''));
totalWidth += width || cellMinWidth;
} else {
fixedWidth = false;
totalWidth += cellMinWidth;
}
}
}
}
console.log(totalWidth);
if (fixedWidth) {
tableDom.style.width = totalWidth + "px"
tableDom.style.minWidth = ""
} else {
tableDom.style.width = ""
tableDom.style.minWidth = totalWidth + "px"
}
}
function zeroes(n) {
let result = []
for (let i = 0; i < n; i++) result.push(0)
return result
}
function handleDecorations(state, cell) {
let decorations = []
let $cell = state.doc.resolve(cell)
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan
for (let row = 0; row < map.height; row++) {
let index = col + row * map.width - 1
// For positions that are have either a different cell or the end
// of the table to their right, and either the top of the table or
// a different cell above them, add a decoration
if ((col == map.width || map.map[index] != map.map[index + 1]) &&
(row == 0 || map.map[index - 1] != map.map[index - 1 - map.width])) {
let cellPos = map.map[index]
let pos = start + cellPos + table.nodeAt(cellPos).nodeSize - 1
let dom = document.createElement("div")
dom.className = "column-resize-handle"
decorations.push(Decoration.widget(pos, dom))
}
}
return DecorationSet.create(state.doc, decorations)
}

View File

@@ -0,0 +1,131 @@
const link = {
attrs: {
href: {},
title: {default: null},
target: {default: null}
},
inclusive: false,
parseDOM: [{
tag: "a[href]", getAttrs: function getAttrs(dom) {
return {
href: dom.getAttribute("href"),
title: dom.getAttribute("title"),
target: dom.getAttribute("target"),
}
}
}],
toDOM: function toDOM(node) {
const ref = node.attrs;
const href = ref.href;
const title = ref.title;
const target = ref.target;
return ["a", {href, title, target}, 0]
}
};
const em = {
parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}],
toDOM() {
return ["em", 0]
}
};
const strong = {
parseDOM: [{tag: "strong"},
// This works around a Google Docs misbehavior where
// pasted content will be inexplicably wrapped in `<b>`
// tags with a font-weight normal.
{
tag: "b", getAttrs: function (node) {
return node.style.fontWeight != "normal" && null;
}
},
{
style: "font-weight", getAttrs: function (value) {
return /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null;
}
}],
toDOM() {
return ["strong", 0]
}
};
const code = {
parseDOM: [{tag: "code"}],
toDOM() {
return ["code", 0]
}
};
const underline = {
parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
toDOM() {
return ["span", {style: "text-decoration: underline;"}, 0];
}
};
const strike = {
parseDOM: [{tag: "s"}, {tag: "strike"}, {style: "text-decoration=line-through"}],
toDOM() {
return ["span", {style: "text-decoration: line-through;"}, 0];
}
};
const superscript = {
parseDOM: [{tag: "sup"}],
toDOM() {
return ["sup", 0];
}
};
const subscript = {
parseDOM: [{tag: "sub"}],
toDOM() {
return ["sub", 0];
}
};
const text_color = {
attrs: {
color: {},
},
parseDOM: [{
style: 'color',
getAttrs(color) {
return {color}
}
}],
toDOM(node) {
return ['span', {style: `color: ${node.attrs.color};`}, 0];
}
};
const background_color = {
attrs: {
color: {},
},
parseDOM: [{
style: 'background-color',
getAttrs(color) {
return {color}
}
}],
toDOM(node) {
return ['span', {style: `background-color: ${node.attrs.color};`}, 0];
}
};
const marks = {
link,
em,
strong,
code,
underline,
strike,
superscript,
subscript,
text_color,
background_color,
};
export default marks;

View File

@@ -0,0 +1,383 @@
import {orderedList, bulletList, listItem} from "prosemirror-schema-list";
import {Fragment} from "prosemirror-model";
/**
* @param {HTMLElement} node
* @return {string|null}
*/
function getAlignAttrFromDomNode(node) {
const classList = node.classList;
const styles = node.style || {};
const alignments = ['right', 'left', 'center', 'justify'];
for (const alignment of alignments) {
if (classList.contains('align-' + alignment) || styles.textAlign === alignment) {
return alignment;
}
}
return null;
}
/**
* @param node
* @param {Object} attrs
* @return {Object}
*/
function addAlignmentAttr(node, attrs) {
const positions = ['right', 'left', 'center', 'justify'];
for (const position of positions) {
if (node.attrs.align === position) {
return addClassToAttrs('align-' + position, attrs);
}
}
return attrs;
}
function getAttrsParserForAlignment(node) {
return {
align: getAlignAttrFromDomNode(node),
};
}
/**
* @param {String} className
* @param {Object} attrs
* @return {Object}
*/
function addClassToAttrs(className, attrs) {
return Object.assign({}, attrs, {
class: attrs.class ? attrs.class + ' ' + className : className,
});
}
/**
* @param {String[]} attrNames
* @return {function(Element): {}}
*/
function domAttrsToAttrsParser(attrNames) {
return function (node) {
const attrs = {};
for (const attr of attrNames) {
attrs[attr] = node.hasAttribute(attr) ? node.getAttribute(attr) : null;
}
return attrs;
};
}
/**
* @param {PmNode} node
* @param {String[]} attrNames
*/
function extractAttrsForDom(node, attrNames) {
const domAttrs = {};
for (const attr of attrNames) {
if (node.attrs[attr]) {
domAttrs[attr] = node.attrs[attr];
}
}
return domAttrs;
}
const doc = {
content: "block+",
};
const paragraph = {
content: "inline*",
group: "block",
parseDOM: [
{
tag: "p",
getAttrs: getAttrsParserForAlignment,
}
],
attrs: {
align: {
default: null,
}
},
toDOM(node) {
return ["p", addAlignmentAttr(node, {}), 0];
}
};
const blockquote = {
content: "block+",
group: "block",
defining: true,
parseDOM: [{tag: "blockquote", getAttrs: getAttrsParserForAlignment}],
attrs: {
align: {
default: null,
}
},
toDOM(node) {
return ["blockquote", addAlignmentAttr(node, {}), 0];
}
};
const horizontal_rule = {
group: "block",
parseDOM: [{tag: "hr"}],
toDOM() {
return ["hr"];
}
};
const headingParseGetAttrs = (level) => {
return function (node) {
return {level, align: getAlignAttrFromDomNode(node)};
};
};
const heading = {
attrs: {level: {default: 1}, align: {default: null}},
content: "inline*",
group: "block",
defining: true,
parseDOM: [
{tag: "h1", getAttrs: headingParseGetAttrs(1)},
{tag: "h2", getAttrs: headingParseGetAttrs(2)},
{tag: "h3", getAttrs: headingParseGetAttrs(3)},
{tag: "h4", getAttrs: headingParseGetAttrs(4)},
{tag: "h5", getAttrs: headingParseGetAttrs(5)},
{tag: "h6", getAttrs: headingParseGetAttrs(6)},
],
toDOM(node) {
return ["h" + node.attrs.level, addAlignmentAttr(node, {}), 0]
}
};
const code_block = {
content: "text*",
marks: "",
group: "block",
code: true,
defining: true,
parseDOM: [{tag: "pre", preserveWhitespace: "full"}],
toDOM() {
return ["pre", ["code", 0]];
}
};
const text = {
group: "inline"
};
const image = {
inline: true,
attrs: {
src: {},
alt: {default: null},
title: {default: null},
height: {default: null},
width: {default: null},
},
group: "inline",
draggable: true,
parseDOM: [{
tag: "img[src]", getAttrs: function getAttrs(dom) {
return {
src: dom.getAttribute("src"),
title: dom.getAttribute("title"),
alt: dom.getAttribute("alt"),
height: dom.getAttribute("height"),
width: dom.getAttribute("width"),
}
}
}],
toDOM: function toDOM(node) {
const ref = node.attrs;
const src = ref.src;
const alt = ref.alt;
const title = ref.title;
const width = ref.width;
const height = ref.height;
return ["img", {src, alt, title, width, height}]
}
};
const iframe = {
attrs: {
src: {},
height: {default: null},
width: {default: null},
title: {default: null},
allow: {default: null},
sandbox: {default: null},
},
group: "block",
draggable: true,
parseDOM: [{
tag: "iframe",
getAttrs: domAttrsToAttrsParser(["src", "width", "height", "title", "allow", "sandbox"]),
}],
toDOM(node) {
const attrs = extractAttrsForDom(node, ["src", "width", "height", "title", "allow", "sandbox"])
return ["iframe", attrs];
}
};
const hard_break = {
inline: true,
group: "inline",
selectable: false,
parseDOM: [{tag: "br"}],
toDOM() {
return ["br"];
}
};
const calloutParseGetAttrs = (type) => {
return function (node) {
return {type, align: getAlignAttrFromDomNode(node)};
};
};
const callout = {
attrs: {
type: {default: 'info'},
align: {default: null},
},
content: "inline*",
group: "block",
defining: true,
parseDOM: [
{tag: 'p.callout.info', getAttrs: calloutParseGetAttrs('info'), priority: 75},
{tag: 'p.callout.success', getAttrs: calloutParseGetAttrs('success'), priority: 75},
{tag: 'p.callout.danger', getAttrs: calloutParseGetAttrs('danger'), priority: 75},
{tag: 'p.callout.warning', getAttrs: calloutParseGetAttrs('warning'), priority: 75},
{tag: 'p.callout', getAttrs: calloutParseGetAttrs('info'), priority: 75},
],
toDOM(node) {
const type = node.attrs.type || 'info';
return ['p', addAlignmentAttr(node, {class: 'callout ' + type}), 0];
}
};
const ordered_list = Object.assign({}, orderedList, {content: "list_item+", group: "block"});
const bullet_list = Object.assign({}, bulletList, {content: "list_item+", group: "block"});
const list_item = Object.assign({}, listItem, {content: 'paragraph block*'});
const table = {
content: "table_row+",
attrs: {
style: {default: null},
},
tableRole: "table",
isolating: true,
group: "block",
parseDOM: [{tag: "table", getAttrs: domAttrsToAttrsParser(['style'])}],
toDOM(node) {
return ["table", extractAttrsForDom(node, ['style']), ["tbody", 0]]
}
};
const table_row = {
content: "(table_cell | table_header)*",
tableRole: "row",
parseDOM: [{tag: "tr"}],
toDOM() { return ["tr", 0] }
};
let cellAttrs = {
colspan: {default: 1},
rowspan: {default: 1},
width: {default: null},
height: {default: null},
};
function getCellAttrs(dom) {
return {
colspan: Number(dom.getAttribute("colspan") || 1),
rowspan: Number(dom.getAttribute("rowspan") || 1),
width: dom.style.width || null,
height: dom.style.height || null,
};
}
function setCellAttrs(node) {
let attrs = {};
const styles = [];
if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan;
if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan;
if (node.attrs.width) styles.push(`width: ${node.attrs.width}`);
if (node.attrs.height) styles.push(`height: ${node.attrs.height}`);
if (styles) {
attrs.style = styles.join(';');
}
return attrs
}
const table_cell = {
content: "block+",
attrs: cellAttrs,
tableRole: "cell",
isolating: true,
parseDOM: [{tag: "td", getAttrs: dom => getCellAttrs(dom)}],
toDOM(node) { return ["td", setCellAttrs(node), 0] }
};
const table_header = {
content: "block+",
attrs: cellAttrs,
tableRole: "header_cell",
isolating: true,
parseDOM: [{tag: "th", getAttrs: dom => getCellAttrs(dom)}],
toDOM(node) { return ["th", setCellAttrs(node), 0] }
};
const details = {
content: "details_summary block*",
isolating: true,
group: "block",
parseDOM: [{
tag: "details",
getAttrs(domNode) {
return {}
},
}],
toDOM(node) {
return ["details", 0];
}
};
const details_summary = {
content: "inline*",
group: "block",
parseDOM: [{
tag: "details summary",
}],
toDOM(node) {
return ["summary", 0];
}
};
const nodes = {
doc,
paragraph,
blockquote,
horizontal_rule,
heading,
code_block,
text,
image,
iframe,
hard_break,
callout,
ordered_list,
bullet_list,
list_item,
table,
table_row,
table_cell,
table_header,
details,
details_summary,
};
export default nodes;

View File

@@ -0,0 +1,12 @@
import {Schema} from "prosemirror-model";
import nodes from "./schema-nodes";
import marks from "./schema-marks";
/** @var {PmSchema} schema */
const schema = new Schema({
nodes,
marks,
});
export default schema;

View File

@@ -0,0 +1,106 @@
/**
* @typedef {Object} PmEditorState
* @property {PmNode} doc
* @property {PmSelection} selection
* @property {PmMark[]|null} storedMarks
* @property {PmSchema} schema
* @property {PmTransaction} tr
*/
/**
* @typedef {Object} PmNode
* @property {PmNodeType} type
* @property {Object} attrs
* @property {PmFragment} content
* @property {PmMark[]} marks
* @property {String|null} text
* @property {Number} nodeSize
* @property {Number} childCount
*/
/**
* @typedef {Object} PmNodeType
*/
/**
* @typedef {Object} PmMark
* @property {PmMarkType} type
* @property {Object} attrs
*/
/**
* @typedef {Object} PmMarkType
* @property {String} name
* @property {PmSchema} schema
* @property {PmMarkSpec} spec
*/
/**
* @typedef {Object} PmMarkSpec
*/
/**
* @typedef {Object} PmSchema
* @property {PmSchema} schema
* @property {Object<PmNodeType>} nodes
* @property {Object<PmMarkType>} marks
* @property {PmNodeType} topNodeType
* @property {Object} cached
*/
/**
* @typedef {Object} PmSelection
* @property {PmSelectionRange[]} ranges
* @property {PmResolvedPos} $anchor
* @property {PmResolvedPos} $head
* @property {Number} anchor
* @property {Number} head
* @property {Number} from
* @property {Number} to
* @property {PmResolvedPos} $from
* @property {PmResolvedPos} $to
* @property {Boolean} empty
*/
/**
* @typedef {Object} PmResolvedPos
* @property {Number} pos
* @property {Number} depth
* @property {Number} parentOffset
* @property {PmNode} parent
* @property {PmNode} doc
*/
/**
* @typedef {Object} PmSelectionRange
*/
/**
* @typedef {Object} PmTransaction
* @property {Number} time
* @property {PmMark[]|null} storedMarks
* @property {PmSelection} selection
*/
/**
* @typedef {Object} PmFragment
*/
/**
* @typedef {Function} PmCommandHandler
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
*/
/**
* @typedef {Function} PmDispatchFunction
* @param {PmTransaction} tr
*/
/**
* @typedef {Object} PmView
* @param {PmEditorState} state
* @param {Element} dom
* @param {Boolean} editable
* @param {Boolean} composing
*/

131
resources/js/editor/util.js Normal file
View File

@@ -0,0 +1,131 @@
import schema from "./schema";
import {DOMParser, DOMSerializer} from "prosemirror-model";
/**
* @param {String} html
* @return {PmNode}
*/
export function htmlToDoc(html) {
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.innerHTML = html;
return DOMParser.fromSchema(schema).parse(renderDoc.body);
}
/**
* @param {PmNode} doc
* @return {string}
*/
export function docToHtml(doc) {
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content);
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.appendChild(fragment);
return renderDoc.body.innerHTML;
}
/**
* @param {PmEditorState} state
* @return {String}
*/
export function stateToHtml(state) {
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(state.doc.content);
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.appendChild(fragment);
return renderDoc.body.innerHTML;
}
/**
* @param {Object} object
* @return {{}}
*/
export function nullifyEmptyValues(object) {
const clean = {};
for (const [key, value] of Object.entries(object)) {
clean[key] = (value === "") ? null : value;
}
return clean;
}
/**
* @param {PmEditorState} state
* @param {PmSelection} selection
* @param {PmMarkType} markType
* @return {{from: Number, to: Number}}
*/
export function expandSelectionToMark(state, selection, markType) {
let {from, to} = selection;
const noRange = (from === to);
if (noRange) {
const markRange = markRangeAtPosition(state, markType, from);
if (markRange.from !== -1) {
from = markRange.from;
to = markRange.to;
}
}
return {from, to};
}
/**
* @param {PmEditorState} state
* @param {PmMarkType} markType
* @param {Number} pos
* @return {{from: Number, to: Number}}
*/
export function markRangeAtPosition(state, markType, pos) {
const $pos = state.doc.resolve(pos);
const {parent, parentOffset} = $pos;
const start = parent.childAfter(parentOffset);
if (!start.node) return {from: -1, to: -1};
const mark = start.node.marks.find((mark) => mark.type === markType);
if (!mark) return {from: -1, to: -1};
let startIndex = $pos.index();
let startPos = $pos.start() + start.offset;
let endIndex = startIndex + 1;
let endPos = startPos + start.node.nodeSize;
while (startIndex > 0 && mark.isInSet(parent.child(startIndex - 1).marks)) {
startIndex -= 1;
startPos -= parent.child(startIndex).nodeSize;
}
while (endIndex < parent.childCount && mark.isInSet(parent.child(endIndex).marks)) {
endPos += parent.child(endIndex).nodeSize;
endIndex += 1;
}
return {from: startPos, to: endPos};
}
/**
* @class KeyedMultiStack
* Holds many stacks, seperated via a key, with a simple
* interface to pop and push values to the stacks.
*/
export class KeyedMultiStack {
constructor() {
this.stack = {};
}
/**
* @param {String} key
* @return {undefined|*}
*/
pop(key) {
if (Array.isArray(this.stack[key])) {
return this.stack[key].pop();
}
return undefined;
}
/**
* @param {String} key
* @param {*} value
*/
push(key, value) {
if (this.stack[key] === undefined) {
this.stack[key] = [];
}
this.stack[key].push(value);
}
}

View File

@@ -211,9 +211,9 @@ function wysiwygView(elem) {
const doc = elem.ownerDocument;
const codeElem = elem.querySelector('code');
let lang = (elem.className || '').replace('language-', '');
if (lang === '' && codeElem) {
lang = (codeElem.className || '').replace('language-', '')
let lang = getLanguageFromCssClasses(elem.className || '');
if (!lang && codeElem) {
lang = getLanguageFromCssClasses(codeElem.className || '');
}
elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
@@ -228,7 +228,7 @@ function wysiwygView(elem) {
elem.parentNode.replaceChild(newWrap, elem);
newWrap.appendChild(newTextArea);
newWrap.contentEditable = false;
newWrap.contentEditable = 'false';
newTextArea.textContent = content;
let cm = CodeMirror(function(elt) {
@@ -245,6 +245,16 @@ function wysiwygView(elem) {
return {wrap: newWrap, editor: cm};
}
/**
* Get the code language from the given css classes.
* @param {String} classes
* @return {String}
*/
function getLanguageFromCssClasses(classes) {
const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
return (langClasses[0] || '').replace('language-', '');
}
/**
* Create a CodeMirror instance to show in the WYSIWYG pop-up editor
* @param {HTMLElement} elem

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'تم إنشاء صفحة',
'page_create_notification' => 'تم إنشاء الصفحة بنجاح',
'page_create_notification' => 'Page successfully created',
'page_update' => 'تم تحديث الصفحة',
'page_update_notification' => 'تم تحديث الصفحة بنجاح',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'تم حذف الصفحة',
'page_delete_notification' => 'تم حذف الصفحة بنجاح',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'تمت استعادة الصفحة',
'page_restore_notification' => 'تمت استعادة الصفحة بنجاح',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'تم نقل الصفحة',
// Chapters
'chapter_create' => 'تم إنشاء فصل',
'chapter_create_notification' => 'تم إنشاء فصل بنجاح',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'تم تحديث الفصل',
'chapter_update_notification' => 'تم تحديث الفصل بنجاح',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'تم حذف الفصل',
'chapter_delete_notification' => 'تم حذف الفصل بنجاح',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'تم نقل الفصل',
// Books
'book_create' => 'تم إنشاء كتاب',
'book_create_notification' => 'تم إنشاء كتاب بنجاح',
'book_create_notification' => 'Book successfully created',
'book_update' => 'تم تحديث الكتاب',
'book_update_notification' => 'تم تحديث الكتاب بنجاح',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'تم حذف الكتاب',
'book_delete_notification' => 'تم حذف الكتاب بنجاح',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'تم سرد الكتاب',
'book_sort_notification' => 'أُعِيدَ سرد الكتاب بنجاح',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'تم إنشاء رف الكتب',
'bookshelf_create_notification' => 'تم إنشاء الرف بنجاح',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'تم تحديث الرف',
'bookshelf_update_notification' => 'تم تحديث الرف بنجاح',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'تم تحديث الرف',
'bookshelf_delete_notification' => 'تم حذف الرف بنجاح',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'تم التعليق',
'permissions_update' => 'تحديث الأذونات',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور',
'password_confirm' => 'تأكيد كلمة المرور',
'password_hint' => 'يجب أن تكون أكثر من 7 حروف',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'نسيت كلمة المرور؟',
'remember_me' => 'تذكرني',
'ldap_email_hint' => 'الرجاء إدخال عنوان بريد إلكتروني لاستخدامه مع الحساب.',
@@ -79,7 +79,7 @@ return [
'mfa_setup_configured' => 'Already configured',
'mfa_setup_reconfigure' => 'Reconfigure',
'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
'mfa_setup_action' => 'Setup',
'mfa_setup_action' => 'إعداد (تنصيب)',
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
'mfa_option_totp_title' => 'Mobile App',
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'عرض منسدل',
'default' => 'افتراضي',
'breadcrumb' => 'شريط التنقل',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'عرض القائمة',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'الفصول الأخيرة',
'books_sort_show_other' => 'عرض كتب أخرى',
'books_sort_save' => 'حفظ الترتيب الجديد',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'فصل',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'نقل الفصل',
'chapters_move_named' => 'نقل فصل :chapterName',
'chapter_move_success' => 'تم نقل الفصل إلى :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'أذونات الفصل',
'chapters_empty' => 'لا توجد أي صفحات في هذا الفصل حالياً',
'chapters_permissions_active' => 'أذونات الفصل مفعلة',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'هل أنت متأكد من أنك تريد استعادة هذه المراجعة؟ سيتم استبدال محتوى الصفحة الحالية.',
'revision_delete_success' => 'تم حذف المراجعة',
'revision_cannot_delete_latest' => 'لايمكن حذف آخر مراجعة.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'أدوار المستخدمين',
'users_role_desc' => 'حدد الأدوار التي سيتم تعيين هذا المستخدم لها. إذا تم تعيين مستخدم لأدوار متعددة ، فسيتم تكديس الأذونات من هذه الأدوار وسيتلقى كل قدرات الأدوار المعينة.',
'users_password' => 'كلمة مرور المستخدم',
'users_password_desc' => 'قم بتعيين كلمة مرور مستخدمة لتسجيل الدخول إلى التطبيق. يجب ألا يقل طول هذه الكلمة عن 6 أحرف.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'يمكنك اختيار إرسال دعوة بالبريد الإلكتروني إلى هذا المستخدم مما يسمح له بتعيين كلمة المرور الخاصة به أو يمكنك تعيين كلمة المرور الخاصة به بنفسك.',
'users_send_invite_option' => 'أرسل بريدًا إلكترونيًا لدعوة المستخدم',
'users_external_auth_id' => 'ربط الحساب بمواقع التواصل',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف رمز API؟',
'user_api_token_delete_success' => 'تم حذف رمز الـ API بنجاح',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'създадена страница',
'page_create_notification' => 'Страницата беше успешно създадена',
'page_create_notification' => 'Page successfully created',
'page_update' => 'обновена страница',
'page_update_notification' => 'Страницата успешно обновена',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'изтрита страница',
'page_delete_notification' => 'Страницата беше успешно изтрита',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'възстановена страница',
'page_restore_notification' => 'Страницата беше успешно възстановена',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'преместена страница',
// Chapters
'chapter_create' => 'създадена страница',
'chapter_create_notification' => 'Главата беше успешно създадена',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'обновена глава',
'chapter_update_notification' => 'Главата беше успешно обновена',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'изтрита глава',
'chapter_delete_notification' => 'Главата беше успешно изтрита',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'преместена глава',
// Books
'book_create' => 'създадена книга',
'book_create_notification' => 'Книгата беше успешно създадена',
'book_create_notification' => 'Book successfully created',
'book_update' => 'обновена книга',
'book_update_notification' => 'Книгата беше успешно обновена',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'изтрита книга',
'book_delete_notification' => 'Книгата беше успешно изтрита',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'сортирана книга',
'book_sort_notification' => 'Книгата беше успешно преподредена',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'създаден рафт',
'bookshelf_create_notification' => 'Рафтът беше успешно създаден',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'обновен рафт',
'bookshelf_update_notification' => 'Рафтът беше успешно обновен',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'изтрит рафт',
'bookshelf_delete_notification' => 'Рафтът беше успешно изтрит',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'коментирано на',
'permissions_update' => 'updated permissions',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'Имейл',
'password' => 'Парола',
'password_confirm' => 'Потвърди паролата',
'password_hint' => 'Трябва да бъде поне 7 символа',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Забравена парола?',
'remember_me' => 'Запомни ме',
'ldap_email_hint' => 'Моля въведете емейл, който да използвате за дадения акаунт.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'Изглед списък',
'default' => 'Основен',
'breadcrumb' => 'Трасиране',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Последна глава',
'books_sort_show_other' => 'Покажи други книги',
'books_sort_save' => 'Запази новата подредба',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Глава',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Премести глава',
'chapters_move_named' => 'Премести глава :chapterName',
'chapter_move_success' => 'Главата беше преместена в :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Настойки за достъп на главата',
'chapters_empty' => 'Няма създадени страници в тази глава.',
'chapters_permissions_active' => 'Настройките за достъп до глава са активни',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'Сигурни ли сте, че искате да изтриете тази версия? Настоящата страница ще бъде заместена.',
'revision_delete_success' => 'Версията беше изтрита',
'revision_cannot_delete_latest' => 'Не може да изтриете последната версия.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'User Roles',
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
'users_password' => 'User Password',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
'users_send_invite_option' => 'Send user invite email',
'users_external_auth_id' => 'External Authentication ID',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
'user_api_token_delete_success' => 'API token successfully deleted',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'je kreirao/la stranicu',
'page_create_notification' => 'Stranica Uspješno Kreirana',
'page_create_notification' => 'Page successfully created',
'page_update' => 'je ažurirao/la stranicu',
'page_update_notification' => 'Stranica Uspješno Ažurirana',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'je izbrisao/la stranicu',
'page_delete_notification' => 'Stranica Uspješno Izbrisana',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'je vratio/la stranicu',
'page_restore_notification' => 'Stranica Uspješno Vraćena',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'je premjestio/la stranicu',
// Chapters
'chapter_create' => 'je kreirao/la poglavlje',
'chapter_create_notification' => 'Poglavlje Uspješno Kreirano',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'je ažurirao/la poglavlje',
'chapter_update_notification' => 'Poglavlje Uspješno Ažurirano',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'je izbrisao/la poglavlje',
'chapter_delete_notification' => 'Poglavlje Uspješno Izbrisano',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'je premjestio/la poglavlje',
// Books
'book_create' => 'je kreirao/la knjigu',
'book_create_notification' => 'Knjiga Uspješno Kreirana',
'book_create_notification' => 'Book successfully created',
'book_update' => 'je ažurirao/la knjigu',
'book_update_notification' => 'Knjiga Uspješno Ažurirana',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'je izbrisao/la knjigu',
'book_delete_notification' => 'Knjiga Uspješno Izbrisana',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'je sortirao/la knjigu',
'book_sort_notification' => 'Knjiga Uspješno Ponovno Sortirana',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'je kreirao/la Policu za knjige',
'bookshelf_create_notification' => 'Polica za knjige Uspješno Kreirana',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'je ažurirao/la policu za knjige',
'bookshelf_update_notification' => 'Polica za knjige Uspješno Ažurirana',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'je izbrisao/la policu za knjige',
'bookshelf_delete_notification' => 'Polica za knjige Uspješno Izbrisana',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" je dodan u tvoje favorite',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'je komentarisao/la na',
'permissions_update' => 'je ažurirao/la dozvole',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'E-mail',
'password' => 'Lozinka',
'password_confirm' => 'Potvrdi lozinku',
'password_hint' => 'Mora imati više od 7 karaktera',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Zaboravljena lozinka?',
'remember_me' => 'Zapamti me',
'ldap_email_hint' => 'Unesite e-mail koji će se koristiti za ovaj račun.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'Prikaz liste',
'default' => 'Početne postavke',
'breadcrumb' => 'Navigacijske stavke',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'Otvori meni u zaglavlju',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Poglavlja zadnja',
'books_sort_show_other' => 'Prikaži druge knjige',
'books_sort_save' => 'Spremi trenutni poredak',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Poglavlje',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Premjesti poglavlje',
'chapters_move_named' => 'Premjesti poglavlje :chapterName',
'chapter_move_success' => 'Poglavlje premješteno u :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Dozvole poglavlja',
'chapters_empty' => 'U ovom poglavlju trenutno nema stranica.',
'chapters_permissions_active' => 'Dozvole za poglavlje su aktivne',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
'revision_delete_success' => 'Revision deleted',
'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'User Roles',
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
'users_password' => 'User Password',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
'users_send_invite_option' => 'Send user invite email',
'users_external_auth_id' => 'External Authentication ID',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
'user_api_token_delete_success' => 'API token successfully deleted',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'ha creat la pàgina',
'page_create_notification' => 'Pàgina creada correctament',
'page_create_notification' => 'Page successfully created',
'page_update' => 'ha actualitzat la pàgina',
'page_update_notification' => 'Pàgina actualitzada correctament',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'ha suprimit una pàgina',
'page_delete_notification' => 'Pàgina suprimida correctament',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'ha restaurat la pàgina',
'page_restore_notification' => 'Pàgina restaurada correctament',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'ha mogut la pàgina',
// Chapters
'chapter_create' => 'ha creat el capítol',
'chapter_create_notification' => 'Capítol creat correctament',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'ha actualitzat el capítol',
'chapter_update_notification' => 'Capítol actualitzat correctament',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'ha suprimit un capítol',
'chapter_delete_notification' => 'Capítol suprimit correctament',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'ha mogut el capítol',
// Books
'book_create' => 'ha creat el llibre',
'book_create_notification' => 'Llibre creat correctament',
'book_create_notification' => 'Book successfully created',
'book_update' => 'ha actualitzat el llibre',
'book_update_notification' => 'Llibre actualitzat correctament',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'ha suprimit un llibre',
'book_delete_notification' => 'Llibre suprimit correctament',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'ha ordenat el llibre',
'book_sort_notification' => 'Llibre reordenat correctament',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'ha creat el prestatge',
'bookshelf_create_notification' => 'Prestatge creat correctament',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'ha actualitzat el prestatge',
'bookshelf_update_notification' => 'Prestatge actualitzat correctament',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'ha suprimit un prestatge',
'bookshelf_delete_notification' => 'Prestatge suprimit correctament',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'ha comentat a',
'permissions_update' => 'ha actualitzat els permisos',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'Adreça electrònica',
'password' => 'Contrasenya',
'password_confirm' => 'Confirmeu la contrasenya',
'password_hint' => 'Cal que tingui més de 7 caràcters',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Heu oblidat la contrasenya?',
'remember_me' => 'Recorda\'m',
'ldap_email_hint' => 'Introduïu una adreça electrònica per a aquest compte.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'Visualització en llista',
'default' => 'Per defecte',
'breadcrumb' => 'Ruta de navegació',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Els capítols al final',
'books_sort_show_other' => 'Mostra altres llibres',
'books_sort_save' => 'Desa l\'ordre nou',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Capítol',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Mou el capítol',
'chapters_move_named' => 'Mou el capítol :chapterName',
'chapter_move_success' => 'S\'ha mogut el capítol a :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Permisos del capítol',
'chapters_empty' => 'De moment, aquest capítol no conté cap pàgina.',
'chapters_permissions_active' => 'S\'han activat els permisos del capítol',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'Segur que voleu restaurar aquesta revisió? Se substituirà el contingut de la pàgina actual.',
'revision_delete_success' => 'S\'ha suprimit la revisió',
'revision_cannot_delete_latest' => 'No es pot suprimir la darrera revisió.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'Rols de l\'usuari',
'users_role_desc' => 'Seleccioneu a quins rols s\'assignarà l\'usuari. Si un usuari s\'assigna a múltiples rols, els permisos dels rols s\'acumularan i l\'usuari rebrà tots els permisos dels rols assignats.',
'users_password' => 'Contrasenya de l\'usuari',
'users_password_desc' => 'Definiu una contrasenya per a iniciar la sessió a l\'aplicació. Cal que tingui un mínim de 6 caràcters.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'Podeu elegir enviar un correu d\'invitació a aquest usuari, la qual cosa li permetrà definir la seva contrasenya, o podeu definir-li una contrasenya vós.',
'users_send_invite_option' => 'Envia un correu d\'invitació a l\'usuari',
'users_external_auth_id' => 'Identificador d\'autenticació extern',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Segur que voleu suprimir aquest testimoni d\'API?',
'user_api_token_delete_success' => 'Testimoni d\'API suprimit correctament',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -11,7 +11,7 @@ return [
'page_update' => 'aktualizoval/a stránku',
'page_update_notification' => 'Stránka byla úspěšně aktualizována',
'page_delete' => 'odstranil/a stránku',
'page_delete_notification' => 'Stránka byla odstraněna',
'page_delete_notification' => 'Stránka byla úspěšně smazána',
'page_restore' => 'obnovil/a stránku',
'page_restore_notification' => 'Stránka byla úspěšně obnovena',
'page_move' => 'přesunul/a stránku',
@@ -22,18 +22,18 @@ return [
'chapter_update' => 'aktualizoval/a kapitolu',
'chapter_update_notification' => 'Kapitola byla úspěšně aktualizována',
'chapter_delete' => 'odstranila/a kapitolu',
'chapter_delete_notification' => 'Kapitola byla odstraněna',
'chapter_delete_notification' => 'Kapitola byla úspěšně odstraněna',
'chapter_move' => 'přesunul/a kapitolu',
// Books
'book_create' => 'vytvořil/a knihu',
'book_create_notification' => 'Kniha byla vytvořena',
'book_create_notification' => 'Kniha byla úspěšně vytvořena',
'book_update' => 'aktualizoval/a knihu',
'book_update_notification' => 'Kniha byla aktualizována',
'book_update_notification' => 'Kniha byla úspěšně aktualizována',
'book_delete' => 'odstranil/a knihu',
'book_delete_notification' => 'Kniha byla odstraněna',
'book_delete_notification' => 'Kniha byla úspěšně odstraněna',
'book_sort' => 'seřadil/a knihu',
'book_sort_notification' => 'Kniha byla seřazena',
'book_sort_notification' => 'Kniha byla úspěšně seřazena',
// Bookshelves
'bookshelf_create' => 'vytvořil/a knihovnu',
@@ -41,7 +41,7 @@ return [
'bookshelf_update' => 'aktualizoval/a knihovnu',
'bookshelf_update_notification' => 'Knihovna byla úspěšně aktualizována',
'bookshelf_delete' => 'odstranil/a knihovnu',
'bookshelf_delete_notification' => 'Knihovna byla odstraněna',
'bookshelf_delete_notification' => 'Knihovna byla úspěšně smazána',
// Favourites
'favourite_add_notification' => '":name" byla přidána do Vašich oblíbených',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Vícefaktorová metoda byla úspěšně nakonfigurována',
'mfa_remove_method_notification' => 'Vícefaktorová metoda byla úspěšně odstraněna',
// Webhooks
'webhook_create' => 'vytvořil/a webhook',
'webhook_create_notification' => 'Webhook byl úspěšně vytvořen',
'webhook_update' => 'aktualizoval/a webhook',
'webhook_update_notification' => 'Webhook byl úspěšně aktualizován',
'webhook_delete' => 'odstranil/a webhook',
'webhook_delete_notification' => 'Webhook byl úspěšně odstraněn',
// Other
'commented_on' => 'okomentoval/a',
'permissions_update' => 'oprávnění upravena',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'E-mail',
'password' => 'Heslo',
'password_confirm' => 'Potvrzení hesla',
'password_hint' => 'Musí mít víc než 7 znaků',
'password_hint' => 'Musí mít alespoň 8 znaků',
'forgot_password' => 'Zapomenuté heslo?',
'remember_me' => 'Zapamatovat si mě',
'ldap_email_hint' => 'Zadejte email, který chcete přiřadit k tomuto účtu.',
@@ -54,7 +54,7 @@ return [
'email_confirm_text' => 'Prosíme potvrďte svou e-mailovou adresu kliknutím na níže uvedené tlačítko:',
'email_confirm_action' => 'Potvrdit e-mail',
'email_confirm_send_error' => 'Potvrzení e-mailu je vyžadováno, ale systém nemohl odeslat e-mail. Obraťte se na správce, abyste se ujistili, že je e-mail správně nastaven.',
'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
'email_confirm_success' => 'Váš email byl ověřen! Nyní byste měli být schopni se touto emailovou adresou přihlásit.',
'email_confirm_resent' => 'E-mail s potvrzením byl znovu odeslán. Zkontrolujte svou příchozí poštu.',
'email_not_confirmed' => 'E-mailová adresa nebyla potvrzena',
@@ -71,40 +71,40 @@ return [
'user_invite_page_welcome' => 'Vítejte v :appName!',
'user_invite_page_text' => 'Pro dokončení vašeho účtu a získání přístupu musíte nastavit heslo, které bude použito k přihlášení do :appName při dalších návštěvách.',
'user_invite_page_confirm_button' => 'Potvrdit heslo',
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
'user_invite_success_login' => 'Heslo bylo nasteaveno, nyní byste měli být schopni přihlásit se nastaveným heslem do aplikace :appName!',
// Multi-factor Authentication
'mfa_setup' => 'Setup Multi-Factor Authentication',
'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
'mfa_setup_configured' => 'Already configured',
'mfa_setup_reconfigure' => 'Reconfigure',
'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
'mfa_setup_action' => 'Setup',
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
'mfa_setup' => 'Nastavit vícefaktorové ověření',
'mfa_setup_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.',
'mfa_setup_configured' => 'Již nastaveno',
'mfa_setup_reconfigure' => 'Přenastavit',
'mfa_setup_remove_confirmation' => 'Opravdu chcete odstranit tuto metodu vícefaktorového ověřování?',
'mfa_setup_action' => 'Nastavit',
'mfa_backup_codes_usage_limit_warning' => 'Zbývá vám méně než 5 záložních kódů. Před vypršením kódu si prosím vygenerujte a uložte novou sadu, abyste se vyhnuli zablokování vašeho účtu.',
'mfa_option_totp_title' => 'Mobilní aplikace',
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
'mfa_option_backup_codes_title' => 'Backup Codes',
'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
'mfa_option_totp_desc' => 'Pro použití vícefaktorového ověření budete potřebovat mobil aplikaci, která podporuje TOTP jako např. Google Authenticator, Authy nebo Microsoft Authenticator.',
'mfa_option_backup_codes_title' => 'Záložní kódy',
'mfa_option_backup_codes_desc' => 'Bezpečně si uložte sadu jednorázových záložních kódů, které můžete použít pro ověření vaší identity.',
'mfa_gen_confirm_and_enable' => 'Potvrdit a povolit',
'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
'mfa_gen_backup_codes_download' => 'Download Codes',
'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
'mfa_gen_backup_codes_title' => 'Nastavení záložních kódů',
'mfa_gen_backup_codes_desc' => 'Uložte níže uvedený seznam kódů na bezpečné místo. Při přístupu k systému budete moci použít jeden z kódů jako druhou metodu ověření.',
'mfa_gen_backup_codes_download' => 'Stáhnout kódy',
'mfa_gen_backup_codes_usage_warning' => 'Každý kód může být použit pouze jednou',
'mfa_gen_totp_title' => 'Nastavení mobilní aplikace',
'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
'mfa_gen_totp_desc' => 'Pro použití vícefaktorového ověření budete potřebovat mobil aplikaci, která podporuje TOTP jako např. Google Authenticator, Authy nebo Microsoft Authenticator.',
'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
'mfa_gen_totp_verify_setup' => 'Verify Setup',
'mfa_gen_totp_verify_setup' => 'Ověřit nastavení',
'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
'mfa_verify_access' => 'Verify Access',
'mfa_gen_totp_provide_code_here' => 'Zde zadejte kód vygenerovaný vaší aplikací',
'mfa_verify_access' => 'Ověřit přístup',
'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
'mfa_verify_no_methods' => 'No Methods Configured',
'mfa_verify_no_methods' => 'Nejsou nastaveny žádné metody',
'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
'mfa_verify_use_totp' => 'Verify using a mobile app',
'mfa_verify_use_backup_codes' => 'Verify using a backup code',
'mfa_verify_backup_code' => 'Backup Code',
'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
'mfa_verify_use_totp' => 'Ověřit pomocí mobil aplikace',
'mfa_verify_use_backup_codes' => 'Ověřit pomocí záložního kódu',
'mfa_verify_backup_code' => 'Záložní kód',
'mfa_verify_backup_code_desc' => 'Níže zadejte jeden z vašich zbývajících záložních kódů:',
'mfa_verify_backup_code_enter_here' => 'Zde zadejte záložní kód',
'mfa_verify_totp_desc' => 'Níže zadejte kód, který jste si vygenerovali pomocí mobil aplikace:',
'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
];

View File

@@ -45,8 +45,8 @@ return [
'unfavourite' => 'Odebrat z oblíbených',
'next' => 'Další',
'previous' => 'Předchozí',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
'filter_active' => 'Aktivní filtr:',
'filter_clear' => 'Zrušit filtr',
// Sort Options
'sort_options' => 'Možnosti řazení',
@@ -71,6 +71,10 @@ return [
'list_view' => 'Zobrazení seznamu',
'default' => 'Výchozí',
'breadcrumb' => 'Drobečková navigace',
'status' => 'Stav',
'status_active' => 'Aktivní',
'status_inactive' => 'Neaktivní',
'never' => 'Never',
// Header
'header_menu_expand' => 'Rozbalit menu v záhlaví',
@@ -78,7 +82,7 @@ return [
'view_profile' => 'Zobrazit profil',
'edit_profile' => 'Upravit profil',
'dark_mode' => 'Tmavý režim',
'light_mode' => 'Světelný režim',
'light_mode' => 'Světlý režim',
// Layout tabs
'tab_info' => 'Informace',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Kapitoly jako poslední',
'books_sort_show_other' => 'Zobrazit ostatní knihy',
'books_sort_save' => 'Uložit nové pořadí',
'books_copy' => 'Kopírovat knihu',
'books_copy_success' => 'Kniha byla úspěšně zkopírována',
// Chapters
'chapter' => 'Kapitola',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Přesunout kapitolu',
'chapters_move_named' => 'Přesunout kapitolu :chapterName',
'chapter_move_success' => 'Kapitola přesunuta do knihy :bookName',
'chapters_copy' => 'Kopírovat kapitolu',
'chapters_copy_success' => 'Kapitola byla úspěšně zkopírována',
'chapters_permissions' => 'Oprávnění kapitoly',
'chapters_empty' => 'Tato kapitola neobsahuje žádné stránky',
'chapters_permissions_active' => 'Oprávnění kapitoly byla aktivována',
@@ -258,16 +262,16 @@ return [
'tags_explain' => "Přidejte si štítky pro lepší kategorizaci knih. \n Štítky mohou nést i hodnotu pro detailnější klasifikaci.",
'tags_add' => 'Přidat další štítek',
'tags_remove' => 'Odstranit tento štítek',
'tags_usages' => 'Total tag usages',
'tags_assigned_pages' => 'Assigned to Pages',
'tags_assigned_chapters' => 'Assigned to Chapters',
'tags_assigned_books' => 'Assigned to Books',
'tags_assigned_shelves' => 'Assigned to Shelves',
'tags_x_unique_values' => ':count unique values',
'tags_all_values' => 'All values',
'tags_view_tags' => 'View Tags',
'tags_view_existing_tags' => 'View existing tags',
'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
'tags_usages' => 'Počet použití štítku',
'tags_assigned_pages' => 'Přiřazeno ke stránkám',
'tags_assigned_chapters' => 'Přiřazeno ke kapitolám',
'tags_assigned_books' => 'Přiřazeno ke knihám',
'tags_assigned_shelves' => 'Přiřazeno ke knihovnám',
'tags_x_unique_values' => ':count jedinečných hodnot',
'tags_all_values' => 'Všechny hodnoty',
'tags_view_tags' => 'Zobrazit štítky',
'tags_view_existing_tags' => 'Zobrazit existující štítky',
'tags_list_empty_hint' => 'Štítky mohou být přiřazeny pomocí postranního panelu editoru stránky nebo při úpravách podrobností knihy, kapitoly nebo knihovny.',
'attachments' => 'Přílohy',
'attachments_explain' => 'Nahrajte soubory nebo připojte odkazy, které se zobrazí na stránce. Budou k nalezení v postranní liště.',
'attachments_explain_instant_save' => 'Změny zde provedené se okamžitě ukládají.',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'Jste si jisti, že chcete obnovit tuto revizi? Aktuální obsah stránky bude nahrazen.',
'revision_delete_success' => 'Revize odstraněna',
'revision_cannot_delete_latest' => 'Nelze odstranit poslední revizi.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Vlastní nastavení oprávnění nebudou zkopírovány.',
'copy_consider_owner' => 'Stanete se vlastníkem veškerého kopírovaného obsahu.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Přílohy stránky nebudou zkopírovány.',
'copy_consider_access' => 'Po změně umístění, vlastníka nebo oprávnění může dojít k tomu, že obsah může být přístupný těm, kteří přístup dříve něměli.',
];

View File

@@ -174,14 +174,14 @@ return [
'users_role' => 'Uživatelské role',
'users_role_desc' => 'Zvolte role, do kterých chcete uživatele zařadit. Pokud bude uživatel zařazen do více rolí, oprávnění z těchto rolí se sloučí a uživateli bude dovoleno vše, k čemu mají jednotlivé role oprávnění.',
'users_password' => 'Heslo uživatele',
'users_password_desc' => 'Zadejte heslo pro přihlášení do aplikace. Heslo musí být nejméně 6 znaků dlouhé.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'Uživateli můžete poslat pozvánku e-mailem, která umožní uživateli, aby si zvolil sám svoje heslo do aplikace a nebo můžete zadat heslo sami.',
'users_send_invite_option' => 'Poslat uživateli pozvánku e-mailem',
'users_external_auth_id' => 'Přihlašovací identifikátor třetích stran',
'users_external_auth_id_desc' => 'ID použité pro rozpoznání tohoto uživatele když komunikuje s externím přihlašovacím systémem.',
'users_password_warning' => 'Vyplňujte pouze v případě, že chcete heslo změnit.',
'users_system_public' => 'Symbolizuje každého nepřihlášeného návštěvníka, který navštívil aplikaci. Nelze ho použít k přihlášení ale je přiřazen automaticky nepřihlášeným.',
'users_delete' => 'Smazat uživatele',
'users_delete' => 'Odstranit uživatele',
'users_delete_named' => 'Odstranit uživatele :userName',
'users_delete_warning' => 'Uživatel \':userName\' bude zcela odstraněn ze systému.',
'users_delete_confirm' => 'Opravdu chcete tohoto uživatele smazat?',
@@ -206,10 +206,10 @@ return [
'users_api_tokens_none' => 'Tento uživatel nemá vytvořené žádné API Tokeny',
'users_api_tokens_create' => 'Vytvořit Token',
'users_api_tokens_expires' => 'Vyprší',
'users_api_tokens_docs' => 'API Dokumentace',
'users_api_tokens_docs' => 'Dokumentace API',
'users_mfa' => 'Vícefázové ověření',
'users_mfa_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.',
'users_mfa_x_methods' => ':count method configured|:count methods configured',
'users_mfa_x_methods' => ':count nastavená metoda|:count nastavených metod',
'users_mfa_configure' => 'Konfigurovat metody',
// API Tokens
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Opravdu chcete odstranit tento API Token?',
'user_api_token_delete_success' => 'API Token byl odstraněn',
// Webhooks
'webhooks' => 'Webhooky',
'webhooks_create' => 'Vytvořit nový webhook',
'webhooks_none_created' => 'Žádné webhooky nebyly doposud vytvořeny.',
'webhooks_edit' => 'Upravit webhook',
'webhooks_save' => 'Uložit webhook',
'webhooks_details' => 'Podrobnosti webhooku',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Události webhooku',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'Všechny události systému',
'webhooks_name' => 'Název webhooku',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook aktivní',
'webhook_events_table_header' => 'Události',
'webhooks_delete' => 'Odstranit webhook',
'webhooks_delete_warning' => 'Webhook s názvem \':webhookName\' bude úplně odstraněn ze systému.',
'webhooks_delete_confirm' => 'Opravdu chcete odstranit tento webhook?',
'webhooks_format_example' => 'Příklad formátu webhooku',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

View File

@@ -7,41 +7,41 @@ return [
// Pages
'page_create' => 'oprettede side',
'page_create_notification' => 'Siden blev oprettet',
'page_create_notification' => 'Page successfully created',
'page_update' => 'opdaterede side',
'page_update_notification' => 'Siden blev opdateret',
'page_update_notification' => 'Page successfully updated',
'page_delete' => 'slettede side',
'page_delete_notification' => 'Siden blev slettet',
'page_delete_notification' => 'Page successfully deleted',
'page_restore' => 'gendannede side',
'page_restore_notification' => 'Siden blev gendannet',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'flyttede side',
// Chapters
'chapter_create' => 'oprettede kapitel',
'chapter_create_notification' => 'Kapitel blev oprettet',
'chapter_create_notification' => 'Chapter successfully created',
'chapter_update' => 'opdaterede kapitel',
'chapter_update_notification' => 'Kapitlet blev opdateret',
'chapter_update_notification' => 'Chapter successfully updated',
'chapter_delete' => 'slettede kapitel',
'chapter_delete_notification' => 'Kapitel blev slettet',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'flyttede kapitel',
// Books
'book_create' => 'oprettede bog',
'book_create_notification' => 'Bogen blev oprettet',
'book_create_notification' => 'Book successfully created',
'book_update' => 'opdaterede bog',
'book_update_notification' => 'Bogen blev opdateret',
'book_update_notification' => 'Book successfully updated',
'book_delete' => 'slettede bog',
'book_delete_notification' => 'Bogen blev slettet',
'book_delete_notification' => 'Book successfully deleted',
'book_sort' => 'sorterede bogen',
'book_sort_notification' => 'Bogen blev re-sorteret',
'book_sort_notification' => 'Book successfully re-sorted',
// Bookshelves
'bookshelf_create' => 'oprettede bogreol',
'bookshelf_create_notification' => 'Bogreolen blev oprettet',
'bookshelf_create' => 'created bookshelf',
'bookshelf_create_notification' => 'Bookshelf successfully created',
'bookshelf_update' => 'opdaterede bogreolen',
'bookshelf_update_notification' => 'Bogreolen blev opdateret',
'bookshelf_update_notification' => 'Bookshelf successfully updated',
'bookshelf_delete' => 'slettede bogreol',
'bookshelf_delete_notification' => 'Bogreolen blev opdateret',
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
// Favourites
'favourite_add_notification' => '":name" er blevet tilføjet til dine favoritter',
@@ -51,6 +51,14 @@ return [
'mfa_setup_method_notification' => 'Multi-faktor metode konfigureret',
'mfa_remove_method_notification' => 'Multi-faktor metode fjernet',
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_update' => 'updated webhook',
'webhook_update_notification' => 'Webhook successfully updated',
'webhook_delete' => 'deleted webhook',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Other
'commented_on' => 'kommenterede til',
'permissions_update' => 'Tilladelser opdateret',

View File

@@ -21,7 +21,7 @@ return [
'email' => 'E-mail',
'password' => 'Adgangskode',
'password_confirm' => 'Bekræft adgangskode',
'password_hint' => 'Skal være på mindst 7 karakterer',
'password_hint' => 'Must be at least 8 characters',
'forgot_password' => 'Glemt Adgangskode?',
'remember_me' => 'Husk mig',
'ldap_email_hint' => 'Angiv venligst din kontos e-mail.',

View File

@@ -71,6 +71,10 @@ return [
'list_view' => 'Listevisning',
'default' => 'Standard',
'breadcrumb' => 'Brødkrumme',
'status' => 'Status',
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
// Header
'header_menu_expand' => 'Udvid header menu',

View File

@@ -143,6 +143,8 @@ return [
'books_sort_chapters_last' => 'Kapitler sidst',
'books_sort_show_other' => 'Vis andre bøger',
'books_sort_save' => 'Gem ny ordre',
'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied',
// Chapters
'chapter' => 'Kapitel',
@@ -161,6 +163,8 @@ return [
'chapters_move' => 'Flyt kapitel',
'chapters_move_named' => 'Flyt kapitel :chapterName',
'chapter_move_success' => 'Kapitel flyttet til :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Kapiteltilladelser',
'chapters_empty' => 'Der er lige nu ingen sider i dette kapitel.',
'chapters_permissions_active' => 'Aktive kapiteltilladelser',
@@ -332,4 +336,12 @@ return [
'revision_restore_confirm' => 'Er du sikker på at du ønsker at gendanne denne revision? Nuværende sideindhold vil blive erstattet.',
'revision_delete_success' => 'Revision slettet',
'revision_cannot_delete_latest' => 'Kan ikke slette seneste revision.',
// Copy view
'copy_consider' => 'Please consider the below when copying content.',
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
'copy_consider_owner' => 'You will become the owner of all copied content.',
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
'copy_consider_attachments' => 'Page attachments will not be copied.',
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
];

View File

@@ -174,7 +174,7 @@ return [
'users_role' => 'Brugerroller',
'users_role_desc' => 'Vælg hvilke roller denne bruger skal tildeles. Hvis en bruger er tildelt flere roller, sammenføres tilladelserne fra disse roller, og de får alle evnerne fra de tildelte roller.',
'users_password' => 'Brugeradgangskode',
'users_password_desc' => 'Sæt et kodeord, der bruges til at logge på applikationen. Dette skal være mindst 6 tegn langt.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
'users_send_invite_text' => 'Du kan vælge at sende denne bruger en invitation på E-Mail, som giver dem mulighed for at indstille deres egen adgangskode, ellers kan du indstille deres adgangskode selv.',
'users_send_invite_option' => 'Send bruger en invitationsmail',
'users_external_auth_id' => 'Ekstern godkendelses ID',
@@ -233,6 +233,34 @@ return [
'user_api_token_delete_confirm' => 'Er du sikker på, at du vil slette denne API-token?',
'user_api_token_delete_success' => 'API-token slettet',
// Webhooks
'webhooks' => 'Webhooks',
'webhooks_create' => 'Create New Webhook',
'webhooks_none_created' => 'No webhooks have yet been created.',
'webhooks_edit' => 'Edit Webhook',
'webhooks_save' => 'Save Webhook',
'webhooks_details' => 'Webhook Details',
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
'webhooks_events' => 'Webhook Events',
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
'webhooks_events_all' => 'All system events',
'webhooks_name' => 'Webhook Name',
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
'webhooks_endpoint' => 'Webhook Endpoint',
'webhooks_active' => 'Webhook Active',
'webhook_events_table_header' => 'Events',
'webhooks_delete' => 'Delete Webhook',
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
'webhooks_format_example' => 'Webhook Format Example',
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
'webhooks_status' => 'Webhook Status',
'webhooks_last_called' => 'Last Called:',
'webhooks_last_errored' => 'Last Errored:',
'webhooks_last_error_message' => 'Last Error Message:',
//! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en.
//!////////////////////////////////

Some files were not shown because too many files have changed in this diff Show More